Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c497794b | ||
|
|
a618eee38e | ||
|
|
d03ad01cac | ||
|
|
77260af157 | ||
|
|
e1d1ec899e | ||
|
|
af9077cb94 | ||
|
|
b1e6242b29 | ||
|
|
fcc5533c83 | ||
|
|
b9d352555f | ||
|
|
19d4d4577d | ||
|
|
8cbc641d20 |
@@ -187,7 +187,7 @@ A tag `v*` on the project's Gitea with a signed **Update File**, a factory image
|
|||||||
_Avoid_: flash, upgrade (alone)
|
_Avoid_: flash, upgrade (alone)
|
||||||
|
|
||||||
**Game**:
|
**Game**:
|
||||||
Something played inside the Games App: Pong, Snake or Tetris. A Game is not an App: it has no tile in the Launcher, and is found again paused when the Games App is left in the middle of one.
|
Something played inside the Games App: Pong, Snake, Tetris, Minesweeper, 2048 or Asteroids. A Game is not an App: it has no tile in the Launcher, and is found again paused when the Games App is left in the middle of one.
|
||||||
_Avoid_: mini-app, toy
|
_Avoid_: mini-app, toy
|
||||||
|
|
||||||
**Update File**:
|
**Update File**:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ What it does today:
|
|||||||
- **Shell:** the firmware's commands on the device itself, with completion, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`.
|
- **Shell:** the firmware's commands on the device itself, with completion, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`.
|
||||||
- **System:** load, tasks, memory, network, battery, live.
|
- **System:** load, tasks, memory, network, battery, live.
|
||||||
- **SSH:** a terminal on another machine, with a password or the device's own key.
|
- **SSH:** a terminal on another machine, with a password or the device's own key.
|
||||||
- **Games:** Pong, Snake and Tetris.
|
- **Games:** Pong, Snake, Tetris, Minesweeper, 2048 and Asteroids.
|
||||||
- **VPN:** a WireGuard tunnel.
|
- **VPN:** a WireGuard tunnel.
|
||||||
- **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot.
|
- **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot.
|
||||||
- **Updates:** signed, from the project's server, the card or a PC, with a rollback if the new firmware fails.
|
- **Updates:** signed, from the project's server, the card or a PC, with a rollback if the new firmware fails.
|
||||||
@@ -190,7 +190,7 @@ The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`,
|
|||||||
|
|
||||||
## Games
|
## Games
|
||||||
|
|
||||||
The Games App (issue #101) holds three Games, shown as a row of icons like Apps in the Launcher: Pong against the device (first to 7; the device's paddle has a top speed, so it can be beaten), Snake (38 x 19, the walls kill) and Tetris (10 x 20, the 7-bag, the next piece shown, a turn that shifts away from a wall, soft and hard drop). Each is a state machine in `lib/games`, host-tested: time and keys go in, the new state and what happened (a bounce, a line) come out, and a seed decides the dice, so a rule can be tested without playing.
|
The Games App (issue #101) holds six Games, shown as a row of icons like Apps in the Launcher. Two wait for a move: Minesweeper (20 x 9 with 28 mines, in cells of 12 pixels, laid at the first dig and never under or next to it; the fastest cleared field is kept) and 2048. Four run on a clock: Asteroids, steered by leaning the device (and Pong too, if `0` says so, tilted up and down) (the accelerometer, around the angle it was held at when the game began; `tiltTurn` in `lib/games` is host-tested), and Pong against the device (first to 7; the device's paddle has a top speed, so it can be beaten), Snake (38 x 19, the walls kill) and Tetris (10 x 20, the 7-bag, the next piece shown, a turn that shifts away from a wall, soft and hard drop). Each is a state machine in `lib/games`, host-tested: time and keys go in, the new state and what happened (a bounce, a line) come out, and a seed decides the dice, so a rule can be tested without playing.
|
||||||
|
|
||||||
Apps used to hear only key presses. `KeyMapper::held()` says which keys are down, for a paddle that moves while a key is held and for Tetris's repeat. An App that is in use with no key pressed says so (`App::keepsScreenOn`): the screen stays lit, and an update waiting for a quiet moment keeps waiting. A Game left in the middle is found again paused. The best of each Game (for Pong, the longest rally) is kept in internal flash. Tetris plays Korobeiniki, the folk tune it has always had, a note at a time on a speaker channel of its own (`lib/games/src/tune.h`, host-tested timing); `0` switches it, `8` and `9` set its loudness, both kept; it hurries with each level. The beeps and the tune obey Settings > This device > Sound & LED.
|
Apps used to hear only key presses. `KeyMapper::held()` says which keys are down, for a paddle that moves while a key is held and for Tetris's repeat. An App that is in use with no key pressed says so (`App::keepsScreenOn`): the screen stays lit, and an update waiting for a quiet moment keeps waiting. A Game left in the middle is found again paused. The best of each Game (for Pong, the longest rally) is kept in internal flash. Tetris plays Korobeiniki, the folk tune it has always had, a note at a time on a speaker channel of its own (`lib/games/src/tune.h`, host-tested timing); `0` switches it, `8` and `9` set its loudness, both kept; it hurries with each level. The beeps and the tune obey Settings > This device > Sound & LED.
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 138 B |
|
After Width: | Height: | Size: 135 B |
|
After Width: | Height: | Size: 100 B |
@@ -329,7 +329,7 @@ inline constexpr KeyHelp kVpn[] = {
|
|||||||
{"; .", "up, down"},
|
{"; .", "up, down"},
|
||||||
};
|
};
|
||||||
|
|
||||||
// games: Games, the three games
|
// games: Games, the games
|
||||||
inline constexpr KeyHelp kGames[] = {
|
inline constexpr KeyHelp kGames[] = {
|
||||||
{"Enter", "this game"},
|
{"Enter", "this game"},
|
||||||
{", /", "the game before, the next"},
|
{", /", "the game before, the next"},
|
||||||
@@ -338,6 +338,8 @@ inline constexpr KeyHelp kGames[] = {
|
|||||||
// games-pong: Games, Pong
|
// games-pong: Games, Pong
|
||||||
inline constexpr KeyHelp kGamesPong[] = {
|
inline constexpr KeyHelp kGamesPong[] = {
|
||||||
{"; .", "the paddle, while held"},
|
{"; .", "the paddle, while held"},
|
||||||
|
{"0", "steer with the keys, or by tilting"},
|
||||||
|
{"9", "tilting the other way up"},
|
||||||
{"Enter", "start, or start again"},
|
{"Enter", "start, or start again"},
|
||||||
{"p", "pause; any key carries on"},
|
{"p", "pause; any key carries on"},
|
||||||
{"`", "the other games; this one waits"},
|
{"`", "the other games; this one waits"},
|
||||||
@@ -365,6 +367,33 @@ inline constexpr KeyHelp kGamesTetris[] = {
|
|||||||
{"`", "the other games; this one waits"},
|
{"`", "the other games; this one waits"},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// games-mines: Games, Minesweeper
|
||||||
|
inline constexpr KeyHelp kGamesMines[] = {
|
||||||
|
{"; . , /", "move; held, it runs"},
|
||||||
|
{"Enter", "dig; on a number, the rest"},
|
||||||
|
{"f", "a flag, on or off"},
|
||||||
|
{"p", "pause; any key carries on"},
|
||||||
|
{"`", "the other games; this one waits"},
|
||||||
|
};
|
||||||
|
|
||||||
|
// games-2048: Games, 2048
|
||||||
|
inline constexpr KeyHelp kGames2048[] = {
|
||||||
|
{"; . , /", "slide every tile that way"},
|
||||||
|
{"Enter", "start, or start again"},
|
||||||
|
{"`", "the other games; this one waits"},
|
||||||
|
};
|
||||||
|
|
||||||
|
// games-asteroids: Games, Asteroids
|
||||||
|
inline constexpr KeyHelp kGamesAsteroids[] = {
|
||||||
|
{"lean", "turn: left or right"},
|
||||||
|
{", /", "turn, while held"},
|
||||||
|
{";", "thrust, while held"},
|
||||||
|
{".", "fire"},
|
||||||
|
{"Enter", "start, or start again"},
|
||||||
|
{"p", "pause; any key carries on"},
|
||||||
|
{"`", "the other games; this one waits"},
|
||||||
|
};
|
||||||
|
|
||||||
// ssh: SSH, the hosts
|
// ssh: SSH, the hosts
|
||||||
inline constexpr KeyHelp kSsh[] = {
|
inline constexpr KeyHelp kSsh[] = {
|
||||||
{"Enter", "connect, open"},
|
{"Enter", "connect, open"},
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#include "asteroids.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace roro::games {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr float kTick = 0.004f;
|
||||||
|
constexpr float kDeadZone = 0.05f, kFullLean = 0.35f; // about 3 degrees of lean, and 20
|
||||||
|
constexpr int kRockScore[4] = {0, 100, 50, 20};
|
||||||
|
constexpr float kPi = 3.14159265f;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
float tiltTurn(float lean, float level) {
|
||||||
|
float d = lean - level;
|
||||||
|
float magnitude = std::fabs(d);
|
||||||
|
if (magnitude < kDeadZone) return 0;
|
||||||
|
float t = std::min(1.0f, (magnitude - kDeadZone) / (kFullLean - kDeadZone));
|
||||||
|
return d > 0 ? t : -t;
|
||||||
|
}
|
||||||
|
|
||||||
|
float Asteroids::Rock::radius() const { return size == 3 ? 12.0f : size == 2 ? 7.0f : 4.0f; }
|
||||||
|
|
||||||
|
float Asteroids::wrap(float v, float max) {
|
||||||
|
while (v < 0) v += max;
|
||||||
|
while (v >= max) v -= max;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shortest way round the wrapping field.
|
||||||
|
bool Asteroids::touches(float ax, float ay, float bx, float by, float distance) {
|
||||||
|
float dx = std::fabs(ax - bx), dy = std::fabs(ay - by);
|
||||||
|
dx = std::min(dx, kWidth - dx);
|
||||||
|
dy = std::min(dy, kHeight - dy);
|
||||||
|
return dx * dx + dy * dy <= distance * distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
Asteroids::Rock Asteroids::rock(float x, float y, uint8_t size) {
|
||||||
|
Rock r{};
|
||||||
|
r.x = x;
|
||||||
|
r.y = y;
|
||||||
|
r.size = size;
|
||||||
|
float speed = (size == 3 ? 18 : size == 2 ? 28 : 40) * (1 + 0.6f * rng_.unit()) * (1 + 0.08f * (wave_ - 1));
|
||||||
|
float heading = rng_.unit() * 2 * kPi;
|
||||||
|
r.vx = std::sin(heading) * speed;
|
||||||
|
r.vy = -std::cos(heading) * speed;
|
||||||
|
for (auto& corner : r.look) corner = static_cast<uint8_t>(185 + rng_.below(71));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Asteroids::start() {
|
||||||
|
score_ = 0;
|
||||||
|
lives_ = kLives;
|
||||||
|
wave_ = 0;
|
||||||
|
over_ = false;
|
||||||
|
bullets_.clear();
|
||||||
|
placeShip(kWidth / 2, kHeight / 2, 0);
|
||||||
|
newWave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Asteroids::placeShip(float x, float y, float angle) {
|
||||||
|
x_ = x;
|
||||||
|
y_ = y;
|
||||||
|
vx_ = vy_ = 0;
|
||||||
|
angle_ = angle;
|
||||||
|
wait_ = 0;
|
||||||
|
shield_ = kShielded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large rocks along the edges, never near the ship.
|
||||||
|
void Asteroids::newWave() {
|
||||||
|
wave_++;
|
||||||
|
rocks_.clear();
|
||||||
|
int count = std::min(kMaxWave, wave_ + 2);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
float x, y;
|
||||||
|
do {
|
||||||
|
x = rng_.unit() * kWidth;
|
||||||
|
y = rng_.unit() * kHeight;
|
||||||
|
} while (touches(x, y, x_, y_, 45));
|
||||||
|
rocks_.push_back(rock(x, y, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t Asteroids::step(float seconds, const AsteroidsInput& in) {
|
||||||
|
uint8_t events = 0;
|
||||||
|
seconds = std::min(seconds, 0.25f);
|
||||||
|
bool fire = in.fire;
|
||||||
|
while (seconds > 0 && !over_) {
|
||||||
|
float dt = std::min(seconds, kTick);
|
||||||
|
seconds -= dt;
|
||||||
|
events |= tick(dt, in, fire);
|
||||||
|
fire = false; // once a press
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t Asteroids::tick(float dt, const AsteroidsInput& in, bool fire) {
|
||||||
|
uint8_t events = 0;
|
||||||
|
thrusting_ = false;
|
||||||
|
if (wait_ > 0) { // between two ships
|
||||||
|
wait_ -= dt;
|
||||||
|
if (wait_ <= 0) placeShip(kWidth / 2, kHeight / 2, 0);
|
||||||
|
} else {
|
||||||
|
angle_ += std::clamp(in.turn, -1.0f, 1.0f) * kTurnSpeed * dt;
|
||||||
|
if (in.thrust) {
|
||||||
|
thrusting_ = true;
|
||||||
|
vx_ += std::sin(angle_) * kThrust * dt;
|
||||||
|
vy_ -= std::cos(angle_) * kThrust * dt;
|
||||||
|
}
|
||||||
|
float slow = std::max(0.0f, 1 - kDrag * dt);
|
||||||
|
vx_ *= slow;
|
||||||
|
vy_ *= slow;
|
||||||
|
float speed = std::sqrt(vx_ * vx_ + vy_ * vy_);
|
||||||
|
if (speed > kTopSpeed) {
|
||||||
|
vx_ *= kTopSpeed / speed;
|
||||||
|
vy_ *= kTopSpeed / speed;
|
||||||
|
}
|
||||||
|
x_ = wrap(x_ + vx_ * dt, kWidth);
|
||||||
|
y_ = wrap(y_ + vy_ * dt, kHeight);
|
||||||
|
if (shield_ > 0) shield_ -= dt;
|
||||||
|
if (fire && static_cast<int>(bullets_.size()) < kMaxBullets) {
|
||||||
|
float sx = std::sin(angle_), sy = -std::cos(angle_);
|
||||||
|
bullets_.push_back({x_ + sx * kShipRadius, y_ + sy * kShipRadius, sx * kBulletSpeed + vx_, sy * kBulletSpeed + vy_, kBulletLife});
|
||||||
|
events |= Shot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& b : bullets_) {
|
||||||
|
b.x = wrap(b.x + b.vx * dt, kWidth);
|
||||||
|
b.y = wrap(b.y + b.vy * dt, kHeight);
|
||||||
|
b.life -= dt;
|
||||||
|
}
|
||||||
|
bullets_.erase(std::remove_if(bullets_.begin(), bullets_.end(), [](const Bullet& b) { return b.life <= 0; }), bullets_.end());
|
||||||
|
for (auto& r : rocks_) {
|
||||||
|
r.x = wrap(r.x + r.vx * dt, kWidth);
|
||||||
|
r.y = wrap(r.y + r.vy * dt, kHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bullet breaks a rock in two smaller ones, or into nothing if it was small.
|
||||||
|
std::vector<Rock> born;
|
||||||
|
for (size_t i = 0; i < rocks_.size();) {
|
||||||
|
Rock& r = rocks_[i];
|
||||||
|
auto hit = std::find_if(bullets_.begin(), bullets_.end(), [&](const Bullet& b) { return touches(b.x, b.y, r.x, r.y, r.radius()); });
|
||||||
|
if (hit == bullets_.end()) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bullets_.erase(hit);
|
||||||
|
score_ += kRockScore[r.size];
|
||||||
|
events |= Hit;
|
||||||
|
if (r.size > 1) {
|
||||||
|
born.push_back(rock(r.x, r.y, static_cast<uint8_t>(r.size - 1)));
|
||||||
|
born.push_back(rock(r.x, r.y, static_cast<uint8_t>(r.size - 1)));
|
||||||
|
}
|
||||||
|
rocks_.erase(rocks_.begin() + static_cast<long>(i));
|
||||||
|
}
|
||||||
|
rocks_.insert(rocks_.end(), born.begin(), born.end());
|
||||||
|
|
||||||
|
if (wait_ <= 0 && shield_ <= 0)
|
||||||
|
for (const Rock& r : rocks_)
|
||||||
|
if (touches(x_, y_, r.x, r.y, r.radius() + kShipRadius - 1)) {
|
||||||
|
events |= Died;
|
||||||
|
lives_--;
|
||||||
|
if (lives_ <= 0) {
|
||||||
|
over_ = true;
|
||||||
|
events |= Over;
|
||||||
|
} else wait_ = kRespawnWait;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rocks_.empty() && !over_) {
|
||||||
|
newWave();
|
||||||
|
events |= Wave;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::games
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "game_rng.h"
|
||||||
|
|
||||||
|
namespace roro::games {
|
||||||
|
|
||||||
|
// What steers the ship in one step: a turn from -1 (as fast as it goes, anticlockwise) to 1
|
||||||
|
// (clockwise), the thrust held or not, and the trigger pressed since the last step.
|
||||||
|
struct AsteroidsInput {
|
||||||
|
float turn = 0;
|
||||||
|
bool thrust = false, fire = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// How the device's tilt becomes a turn (issue #101's follow-up): leaning it is turning, the
|
||||||
|
// further the faster, around the angle it was held at when the game began. `lean` and `level`
|
||||||
|
// are "up" along the device's left-right axis, -1 to 1, as the accelerometer gives it.
|
||||||
|
float tiltTurn(float lean, float level);
|
||||||
|
|
||||||
|
// Asteroids: a ship in the middle of a field that wraps round, rocks that break in two and again
|
||||||
|
// in two, and bullets that live a moment. Three ships; a new wave when the last rock is gone.
|
||||||
|
// Lengths are pixels of the 240 x 123 field, times seconds.
|
||||||
|
class Asteroids {
|
||||||
|
public:
|
||||||
|
static constexpr float kWidth = 240, kHeight = 123;
|
||||||
|
static constexpr float kShipRadius = 4, kTurnSpeed = 4.7f; // radians a second, at a full turn
|
||||||
|
static constexpr float kThrust = 130, kDrag = 0.6f, kTopSpeed = 140;
|
||||||
|
static constexpr float kBulletSpeed = 190, kBulletLife = 0.85f;
|
||||||
|
static constexpr int kMaxBullets = 4, kLives = 3, kMaxWave = 8;
|
||||||
|
static constexpr float kRespawnWait = 1.5f, kShielded = 2.0f; // seconds out, then seconds safe
|
||||||
|
|
||||||
|
enum Event : uint8_t { Shot = 1, Hit = 2, Died = 4, Wave = 8, Over = 16 };
|
||||||
|
|
||||||
|
struct Rock {
|
||||||
|
float x, y, vx, vy;
|
||||||
|
uint8_t size; // 3 large, 2 middle, 1 small
|
||||||
|
uint8_t look[8]; // the outline: how far out each of its eight corners is, of 255
|
||||||
|
float radius() const;
|
||||||
|
};
|
||||||
|
struct Bullet {
|
||||||
|
float x, y, vx, vy, life;
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit Asteroids(uint32_t seed) : rng_(seed) { start(); }
|
||||||
|
void start();
|
||||||
|
uint8_t step(float seconds, const AsteroidsInput& in);
|
||||||
|
|
||||||
|
float shipX() const { return x_; }
|
||||||
|
float shipY() const { return y_; }
|
||||||
|
float shipAngle() const { return angle_; } // radians, 0 pointing up, clockwise
|
||||||
|
bool shipShown() const { return wait_ <= 0 && !over_; }
|
||||||
|
bool shielded() const { return shield_ > 0; }
|
||||||
|
bool thrusting() const { return thrusting_; }
|
||||||
|
const std::vector<Rock>& rocks() const { return rocks_; }
|
||||||
|
const std::vector<Bullet>& bullets() const { return bullets_; }
|
||||||
|
int score() const { return score_; }
|
||||||
|
int lives() const { return lives_; } // ships left, the one flying included
|
||||||
|
int wave() const { return wave_; }
|
||||||
|
bool over() const { return over_; }
|
||||||
|
// For the tests: the rocks set by hand, and the ship put somewhere.
|
||||||
|
void setRocks(const std::vector<Rock>& rocks) { rocks_ = rocks; }
|
||||||
|
void placeShip(float x, float y, float angle);
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint8_t tick(float dt, const AsteroidsInput& in, bool fire);
|
||||||
|
void newWave();
|
||||||
|
Rock rock(float x, float y, uint8_t size);
|
||||||
|
static float wrap(float v, float max);
|
||||||
|
static bool touches(float ax, float ay, float bx, float by, float distance);
|
||||||
|
|
||||||
|
Rng rng_;
|
||||||
|
std::vector<Rock> rocks_;
|
||||||
|
std::vector<Bullet> bullets_;
|
||||||
|
float x_ = 0, y_ = 0, vx_ = 0, vy_ = 0, angle_ = 0, wait_ = 0, shield_ = 0;
|
||||||
|
int score_ = 0, lives_ = kLives, wave_ = 0;
|
||||||
|
bool over_ = false, thrusting_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro::games
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#include "mines.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace roro::games {
|
||||||
|
|
||||||
|
void Mines::start() {
|
||||||
|
cells_.fill(0);
|
||||||
|
phase_ = Phase::Fresh;
|
||||||
|
laid_ = false;
|
||||||
|
flags_ = opened_ = 0;
|
||||||
|
elapsedMs_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Mines::count() {
|
||||||
|
for (int row = 0; row < kRows; row++)
|
||||||
|
for (int col = 0; col < kCols; col++) {
|
||||||
|
int n = 0;
|
||||||
|
for (int dr = -1; dr <= 1; dr++)
|
||||||
|
for (int dc = -1; dc <= 1; dc++)
|
||||||
|
if ((dr || dc) && inside(col + dc, row + dr) && mine(col + dc, row + dr)) n++;
|
||||||
|
cells_[at(col, row)] = static_cast<uint8_t>((cells_[at(col, row)] & ~kCount) | n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Mines::lay(const uint16_t* where, int n) {
|
||||||
|
for (auto& c : cells_) c &= static_cast<uint8_t>(~kMine);
|
||||||
|
for (int i = 0; i < n; i++) cells_[where[i]] |= kMine;
|
||||||
|
laid_ = true;
|
||||||
|
count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// kMines of them, anywhere but under the first dig and the eight cells around it.
|
||||||
|
void Mines::layAwayFrom(int col, int row) {
|
||||||
|
int placed = 0;
|
||||||
|
while (placed < kMines) {
|
||||||
|
int c = rng_.below(kCols), r = rng_.below(kRows);
|
||||||
|
if ((c >= col - 1 && c <= col + 1 && r >= row - 1 && r <= row + 1) || mine(c, r)) continue;
|
||||||
|
cells_[at(c, r)] |= kMine;
|
||||||
|
placed++;
|
||||||
|
}
|
||||||
|
laid_ = true;
|
||||||
|
count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens a cell, and from a cell with no mine around it, everything up to the numbers.
|
||||||
|
uint8_t Mines::reveal(int col, int row) {
|
||||||
|
if (open(col, row) || flagged(col, row)) return 0;
|
||||||
|
if (mine(col, row)) {
|
||||||
|
cells_[at(col, row)] |= kOpen;
|
||||||
|
phase_ = Phase::Lost;
|
||||||
|
return Boom;
|
||||||
|
}
|
||||||
|
std::vector<uint16_t> todo{static_cast<uint16_t>(at(col, row))};
|
||||||
|
while (!todo.empty()) {
|
||||||
|
int i = todo.back();
|
||||||
|
todo.pop_back();
|
||||||
|
if (cells_[static_cast<size_t>(i)] & (kOpen | kFlag)) continue;
|
||||||
|
cells_[static_cast<size_t>(i)] |= kOpen;
|
||||||
|
opened_++;
|
||||||
|
if (cells_[static_cast<size_t>(i)] & kCount) continue;
|
||||||
|
int c = i % kCols, r = i / kCols;
|
||||||
|
for (int dr = -1; dr <= 1; dr++)
|
||||||
|
for (int dc = -1; dc <= 1; dc++)
|
||||||
|
if ((dr || dc) && inside(c + dc, r + dr) && !open(c + dc, r + dr)) todo.push_back(static_cast<uint16_t>(at(c + dc, r + dr)));
|
||||||
|
}
|
||||||
|
return Opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t Mines::settle(uint8_t events) {
|
||||||
|
if (phase_ != Phase::Lost && opened_ == kCols * kRows - kMines) {
|
||||||
|
phase_ = Phase::Won;
|
||||||
|
events |= Won;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t Mines::dig(int col, int row) {
|
||||||
|
if (!inside(col, row) || phase_ == Phase::Lost || phase_ == Phase::Won) return 0;
|
||||||
|
if (!laid_) layAwayFrom(col, row);
|
||||||
|
if (phase_ == Phase::Fresh) phase_ = Phase::Running;
|
||||||
|
if (!open(col, row)) return settle(reveal(col, row));
|
||||||
|
// An open number with all its flags placed: what is left around it is opened, mines included
|
||||||
|
// if a flag is wrong.
|
||||||
|
int flags = 0;
|
||||||
|
for (int dr = -1; dr <= 1; dr++)
|
||||||
|
for (int dc = -1; dc <= 1; dc++)
|
||||||
|
if ((dr || dc) && inside(col + dc, row + dr) && flagged(col + dc, row + dr)) flags++;
|
||||||
|
if (!around(col, row) || flags != around(col, row)) return 0;
|
||||||
|
uint8_t events = 0;
|
||||||
|
for (int dr = -1; dr <= 1; dr++)
|
||||||
|
for (int dc = -1; dc <= 1; dc++)
|
||||||
|
if ((dr || dc) && inside(col + dc, row + dr)) events |= reveal(col + dc, row + dr);
|
||||||
|
return settle(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t Mines::flag(int col, int row) {
|
||||||
|
if (!inside(col, row) || phase_ == Phase::Lost || phase_ == Phase::Won || open(col, row)) return 0;
|
||||||
|
cells_[at(col, row)] ^= kFlag;
|
||||||
|
flags_ += flagged(col, row) ? 1 : -1;
|
||||||
|
return Flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Mines::tick(uint32_t ms) {
|
||||||
|
if (phase_ == Phase::Running) elapsedMs_ += ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::games
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "game_rng.h"
|
||||||
|
|
||||||
|
namespace roro::games {
|
||||||
|
|
||||||
|
// Minesweeper (issue #101's follow-up): a field of 20 x 9 with 28 mines, cells big enough to read
|
||||||
|
// on this screen. The mines are laid at
|
||||||
|
// the first dig, never under it or next to it, so the first dig always opens something. Digging
|
||||||
|
// an open number whose flags are all placed opens what is left around it. The clock runs from the
|
||||||
|
// first dig to the end.
|
||||||
|
class Mines {
|
||||||
|
public:
|
||||||
|
static constexpr int kCols = 20, kRows = 9, kMines = 28; // the classic "intermediate" share: one cell in six or seven
|
||||||
|
|
||||||
|
enum Event : uint8_t { Opened = 1, Flagged = 2, Boom = 4, Won = 8 };
|
||||||
|
enum class Phase : uint8_t { Fresh, Running, Lost, Won };
|
||||||
|
|
||||||
|
explicit Mines(uint32_t seed) : rng_(seed) { start(); }
|
||||||
|
void start();
|
||||||
|
uint8_t dig(int col, int row);
|
||||||
|
uint8_t flag(int col, int row); // put on a closed cell, or taken off
|
||||||
|
void tick(uint32_t ms); // the clock, while the game runs
|
||||||
|
|
||||||
|
Phase phase() const { return phase_; }
|
||||||
|
bool open(int col, int row) const { return cells_[at(col, row)] & kOpen; }
|
||||||
|
bool flagged(int col, int row) const { return cells_[at(col, row)] & kFlag; }
|
||||||
|
bool mine(int col, int row) const { return cells_[at(col, row)] & kMine; }
|
||||||
|
int around(int col, int row) const { return cells_[at(col, row)] & kCount; } // mines next to it
|
||||||
|
int minesLeft() const { return kMines - flags_; } // by the flags placed, right or wrong
|
||||||
|
uint32_t seconds() const { return elapsedMs_ / 1000; }
|
||||||
|
// For the tests: the mines put where one wants them, before any dig.
|
||||||
|
void lay(const uint16_t* where, int count);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr uint8_t kCount = 0x0F, kMine = 0x10, kOpen = 0x20, kFlag = 0x40;
|
||||||
|
static size_t at(int col, int row) { return static_cast<size_t>(row * kCols + col); }
|
||||||
|
static bool inside(int col, int row) { return col >= 0 && col < kCols && row >= 0 && row < kRows; }
|
||||||
|
void layAwayFrom(int col, int row);
|
||||||
|
void count();
|
||||||
|
uint8_t reveal(int col, int row);
|
||||||
|
uint8_t settle(uint8_t events);
|
||||||
|
|
||||||
|
Rng rng_;
|
||||||
|
std::array<uint8_t, kCols * kRows> cells_{};
|
||||||
|
Phase phase_ = Phase::Fresh;
|
||||||
|
bool laid_ = false;
|
||||||
|
int flags_ = 0, opened_ = 0;
|
||||||
|
uint32_t elapsedMs_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro::games
|
||||||
@@ -11,6 +11,17 @@ constexpr float kSteepest = 0.87f; // 50 degrees, in radians: off the very end
|
|||||||
float clampPaddle(float y) { return std::clamp(y, Pong::kPaddleH / 2, Pong::kHeight - Pong::kPaddleH / 2); }
|
float clampPaddle(float y) { return std::clamp(y, Pong::kPaddleH / 2, Pong::kHeight - Pong::kPaddleH / 2); }
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
float tiltTarget(float pitch, float level) {
|
||||||
|
constexpr float kFullTilt = 0.26f; // 15 degrees either way covers the whole field
|
||||||
|
float travel = Pong::kHeight / 2 - Pong::kPaddleH / 2;
|
||||||
|
return Pong::kHeight / 2 + std::clamp((pitch - level) / kFullTilt, -1.0f, 1.0f) * travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
float moveToward(float paddleY, float targetY) {
|
||||||
|
constexpr float kSlowWithin = 10; // pixels: closer than that, it eases in
|
||||||
|
return std::clamp((targetY - paddleY) / kSlowWithin, -1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
void Pong::start() {
|
void Pong::start() {
|
||||||
playerScore_ = deviceScore_ = rally_ = longest_ = 0;
|
playerScore_ = deviceScore_ = rally_ = longest_ = 0;
|
||||||
player_ = device_ = kHeight / 2;
|
player_ = device_ = kHeight / 2;
|
||||||
@@ -39,7 +50,7 @@ void Pong::bounceOff(float paddleY, bool toRight) {
|
|||||||
vy_ = std::sin(where * kSteepest) * speed_;
|
vy_ = std::sin(where * kSteepest) * speed_;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t Pong::step(float seconds, int move) {
|
uint8_t Pong::step(float seconds, float move) {
|
||||||
uint8_t events = 0;
|
uint8_t events = 0;
|
||||||
seconds = std::min(seconds, 0.25f); // a held-up loop is not a quarter of the field
|
seconds = std::min(seconds, 0.25f); // a held-up loop is not a quarter of the field
|
||||||
while (seconds > 0 && phase_ != Phase::Over) {
|
while (seconds > 0 && phase_ != Phase::Over) {
|
||||||
@@ -50,8 +61,8 @@ uint8_t Pong::step(float seconds, int move) {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t Pong::tick(float dt, int move) {
|
uint8_t Pong::tick(float dt, float move) {
|
||||||
player_ = clampPaddle(player_ + (move < 0 ? -1 : move > 0 ? 1 : 0) * kPlayerSpeed * dt);
|
player_ = clampPaddle(player_ + std::clamp(move, -1.0f, 1.0f) * kPlayerSpeed * dt);
|
||||||
// The device watches the ball once it comes its way, and waits in the middle otherwise.
|
// The device watches the ball once it comes its way, and waits in the middle otherwise.
|
||||||
float target = vx_ > 0 && phase_ == Phase::Play ? y_ : kHeight / 2;
|
float target = vx_ > 0 && phase_ == Phase::Play ? y_ : kHeight / 2;
|
||||||
float reach = kDeviceSpeed * dt;
|
float reach = kDeviceSpeed * dt;
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ namespace roro::games {
|
|||||||
// Pong against the device (issue #101): the player's paddle on the left, the device's on the
|
// Pong against the device (issue #101): the player's paddle on the left, the device's on the
|
||||||
// right, first to 7. The ball goes faster with each return, and the device's paddle only moves so
|
// right, first to 7. The ball goes faster with each return, and the device's paddle only moves so
|
||||||
// fast: a steep, fast ball gets past it. Lengths are pixels of the 240 x 122 field, times seconds.
|
// fast: a steep, fast ball gets past it. Lengths are pixels of the 240 x 122 field, times seconds.
|
||||||
|
// Where the paddle is asked to be when the device is tilted (issue #101's follow-up): its top
|
||||||
|
// lowered, the paddle goes up, as a ball rolls to the low side (it felt the right way round on the
|
||||||
|
// device); held as it was when the game began, the middle. `pitch` and
|
||||||
|
// `level` are "up" along the device's top-bottom axis, -1 to 1, as the accelerometer gives it.
|
||||||
|
float tiltTarget(float pitch, float level);
|
||||||
|
// The move that brings the paddle at `paddleY` to `targetY` with no overshoot: full speed far away,
|
||||||
|
// slower close by.
|
||||||
|
float moveToward(float paddleY, float targetY);
|
||||||
|
|
||||||
class Pong {
|
class Pong {
|
||||||
public:
|
public:
|
||||||
static constexpr float kWidth = 240, kHeight = 122;
|
static constexpr float kWidth = 240, kHeight = 122;
|
||||||
@@ -23,9 +32,9 @@ class Pong {
|
|||||||
|
|
||||||
explicit Pong(uint32_t seed) : rng_(seed) { start(); }
|
explicit Pong(uint32_t seed) : rng_(seed) { start(); }
|
||||||
void start();
|
void start();
|
||||||
// `seconds` later, with the player's paddle asked up (-1), down (1) or left alone (0).
|
// `seconds` later, with the player's paddle asked up (-1), down (1) or left alone (0), or
|
||||||
// What happened, as Events.
|
// anything between for a slower move. What happened, as Events.
|
||||||
uint8_t step(float seconds, int move);
|
uint8_t step(float seconds, float move);
|
||||||
|
|
||||||
Phase phase() const { return phase_; }
|
Phase phase() const { return phase_; }
|
||||||
float ballX() const { return x_; }
|
float ballX() const { return x_; }
|
||||||
@@ -40,7 +49,7 @@ class Pong {
|
|||||||
int longestRally() const { return longest_; } // in this game
|
int longestRally() const { return longest_; } // in this game
|
||||||
|
|
||||||
private:
|
private:
|
||||||
uint8_t tick(float dt, int move);
|
uint8_t tick(float dt, float move);
|
||||||
void serve(bool toPlayer);
|
void serve(bool toPlayer);
|
||||||
bool meets(float paddleY) const;
|
bool meets(float paddleY) const;
|
||||||
void bounceOff(float paddleY, bool toRight);
|
void bounceOff(float paddleY, bool toRight);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#include "twenty48.h"
|
||||||
|
|
||||||
|
namespace roro::games {
|
||||||
|
|
||||||
|
void Twenty48::start() {
|
||||||
|
cells_.fill(0);
|
||||||
|
score_ = 0;
|
||||||
|
over_ = reached_ = false;
|
||||||
|
spawn();
|
||||||
|
spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Twenty48::set(const uint8_t powers[kSize * kSize]) {
|
||||||
|
for (size_t i = 0; i < cells_.size(); i++) cells_[i] = powers[i];
|
||||||
|
over_ = !canMove();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Twenty48::spawn() {
|
||||||
|
int empty = 0;
|
||||||
|
for (uint8_t c : cells_) empty += c == 0;
|
||||||
|
if (!empty) return;
|
||||||
|
int pick = rng_.below(empty);
|
||||||
|
uint8_t tile = rng_.below(10) == 0 ? 2 : 1;
|
||||||
|
for (auto& c : cells_) {
|
||||||
|
if (c) continue;
|
||||||
|
if (pick-- == 0) {
|
||||||
|
c = tile;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Twenty48::canMove() const {
|
||||||
|
for (int row = 0; row < kSize; row++)
|
||||||
|
for (int col = 0; col < kSize; col++) {
|
||||||
|
uint8_t p = power(col, row);
|
||||||
|
if (!p) return true;
|
||||||
|
if (col + 1 < kSize && power(col + 1, row) == p) return true;
|
||||||
|
if (row + 1 < kSize && power(col, row + 1) == p) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t Twenty48::slide(Dir dir) {
|
||||||
|
if (over_) return 0;
|
||||||
|
uint8_t events = 0;
|
||||||
|
// Each of the four lines, read from the side the tiles slide to.
|
||||||
|
for (int line = 0; line < kSize; line++) {
|
||||||
|
size_t index[kSize];
|
||||||
|
for (int i = 0; i < kSize; i++) {
|
||||||
|
int col = dir == Dir::Left ? i : dir == Dir::Right ? kSize - 1 - i : line;
|
||||||
|
int row = dir == Dir::Up ? i : dir == Dir::Down ? kSize - 1 - i : line;
|
||||||
|
index[i] = static_cast<size_t>(row * kSize + col);
|
||||||
|
}
|
||||||
|
uint8_t out[kSize] = {};
|
||||||
|
int n = 0;
|
||||||
|
bool merged = false; // the last tile put down came from a merge: it takes no other
|
||||||
|
for (int i = 0; i < kSize; i++) {
|
||||||
|
uint8_t p = cells_[index[i]];
|
||||||
|
if (!p) continue;
|
||||||
|
if (n > 0 && out[n - 1] == p && !merged) {
|
||||||
|
out[n - 1] = static_cast<uint8_t>(p + 1);
|
||||||
|
score_ += 1u << (p + 1);
|
||||||
|
merged = true;
|
||||||
|
events |= Merged;
|
||||||
|
if (p + 1 == 11 && !reached_) {
|
||||||
|
reached_ = true;
|
||||||
|
events |= Reached;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out[n++] = p;
|
||||||
|
merged = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < kSize; i++) {
|
||||||
|
if (cells_[index[i]] != out[i]) events |= Moved;
|
||||||
|
cells_[index[i]] = out[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!events) return 0;
|
||||||
|
spawn();
|
||||||
|
if (!canMove()) {
|
||||||
|
over_ = true;
|
||||||
|
events |= Over;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::games
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "game_rng.h"
|
||||||
|
|
||||||
|
namespace roro::games {
|
||||||
|
|
||||||
|
// 2048: a board of 4 x 4. A move slides every tile as far as it goes; two alike that meet become
|
||||||
|
// one of twice the value, once a move. Then a new tile comes, a 2 nine times in ten, else a 4.
|
||||||
|
// The game is over when nothing can move. Reaching 2048 is said once, and the game goes on.
|
||||||
|
class Twenty48 {
|
||||||
|
public:
|
||||||
|
static constexpr int kSize = 4;
|
||||||
|
enum class Dir : uint8_t { Up, Right, Down, Left };
|
||||||
|
enum Event : uint8_t { Moved = 1, Merged = 2, Reached = 4, Over = 8 };
|
||||||
|
|
||||||
|
explicit Twenty48(uint32_t seed) : rng_(seed) { start(); }
|
||||||
|
void start();
|
||||||
|
uint8_t slide(Dir dir); // 0 when nothing can move that way
|
||||||
|
|
||||||
|
// A tile's value (2, 4, 8...), or 0 for an empty place.
|
||||||
|
uint32_t value(int col, int row) const { return power(col, row) ? 1u << power(col, row) : 0; }
|
||||||
|
uint8_t power(int col, int row) const { return cells_[static_cast<size_t>(row * kSize + col)]; } // 1 for a 2, 11 for 2048
|
||||||
|
uint32_t score() const { return score_; } // the sum of every tile made by a merge
|
||||||
|
bool over() const { return over_; }
|
||||||
|
bool reached() const { return reached_; }
|
||||||
|
// For the tests: the board set, a row after another, in powers of two.
|
||||||
|
void set(const uint8_t powers[kSize * kSize]);
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool canMove() const;
|
||||||
|
void spawn();
|
||||||
|
|
||||||
|
Rng rng_;
|
||||||
|
std::array<uint8_t, kSize * kSize> cells_{};
|
||||||
|
uint32_t score_ = 0;
|
||||||
|
bool over_ = false, reached_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro::games
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
+++
|
||||||
|
title = "The Apps"
|
||||||
|
description = "Everything on the device, an App at a time: what it does, a screen from the device itself, and where its guide is."
|
||||||
|
template = "apps.html"
|
||||||
|
+++
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,215 @@
|
|||||||
|
+++
|
||||||
|
title = '''Not finished until it plays Tetris'''
|
||||||
|
description = '''roro9stack had a radio scanner, an SSH client and a VPN, and no games, which for a firmware means it wasn't finished. Now it has Pong, Snake and Tetris, with the tune, in 14.6 KB of flash. Seventeen design questions were answered with one word each, and then an evening of playing overturned three of the answers.'''
|
||||||
|
date = 2026-10-13T23:30:00+02:00
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
topics = '''ESP32-S3 · Games · Chiptune'''
|
||||||
|
read_label = '''Read how three games got in →'''
|
||||||
|
uid = '''<b>tetris:</b> level 1, music 6/8'''
|
||||||
|
dek = "[roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer, could sniff a LoRa mesh, browse Gemini and open a shell on a server. It could not play Snake. That is fixed: a Games App with Pong, Snake and Tetris, and the tune Tetris has always been played to. This post is about why a firmware needs them, what three very old games still do to people, and how little room fun takes."
|
||||||
|
byline = '''seventeen questions answered "Ok", then eight things changed by playing'''
|
||||||
|
|
||||||
|
[extra.sign]
|
||||||
|
label = "Bytes of flash for three games and a folk song"
|
||||||
|
note = "The Tetris cartridge for the Game Boy was 32,768."
|
||||||
|
count = "14,616"
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The Launcher"
|
||||||
|
role = "a grid of twelve tiles"
|
||||||
|
text = "Had eleven Apps in it. Three games would have made fourteen, so there is one Games App, and the grid is full."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The key mapper"
|
||||||
|
role = "turns the keyboard into events"
|
||||||
|
text = "Told Apps when a key went down and never that it was still down. Fine for a menu. Useless for a paddle."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "Korobeiniki"
|
||||||
|
role = "a Russian folk song, 1861"
|
||||||
|
text = "The tune of Tetris. Fifty-eight notes in 232 bytes. I had decided there would be no music, and held to that until Tetris was running."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The 1 key"
|
||||||
|
role = "top left of the keyboard"
|
||||||
|
text = "Drops the piece. It was going to be Space, which on this keyboard sits right next to the arrow for 'right'. A drop cannot be taken back."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "A script"
|
||||||
|
role = "playing over Wi-Fi"
|
||||||
|
text = "Presses keys through the Debug Console and reads the screen back. Played the Snake in this post's picture, and lost 35 games in the two runs I counted."
|
||||||
|
+++
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
- **A Games App with Pong, Snake and Tetris** (**v0.24.0**).
|
||||||
|
- **It costs 14.6 KB of flash and 8 bytes of static memory.** The games allocate their few hundred bytes when one is started.
|
||||||
|
- **Each game is a small state machine with no screen in it:** time and keys go in, the new state comes out, and a seed decides the dice. 44 tests play them on the PC.
|
||||||
|
- **Apps can now ask which keys are held.** Until this week they only heard presses.
|
||||||
|
- **Tetris plays Korobeiniki,** faster with each level. `0` switches it, `8` and `9` set its volume.
|
||||||
|
- **Playing changed what the design round had decided:** icons instead of a list, the drop moved off Space, and music after all.
|
||||||
|
- 673 host tests, 47 more than the firmware had without games.
|
||||||
|
|
||||||
|
## The cast
|
||||||
|
|
||||||
|
{{ cast() }}
|
||||||
|
|
||||||
|
## It wasn't finished
|
||||||
|
|
||||||
|
A firmware that can't play a game isn't done. I don't mean that as a joke.
|
||||||
|
|
||||||
|
Look at what people do the moment a machine has a screen and a few buttons. Emacs has had `M-x tetris` for decades, next to `M-x snake` and `M-x pong`. The BSDs still ship `tetris(6)` and `snake(6)` in `/usr/games`. An HP oscilloscope from the nineties, a serious instrument that cost a serious amount, hides a Tetris behind a key sequence. The Flipper Zero comes with Snake. Chrome has a dinosaur for when the network is down. Nobody needed any of these. Somebody put each of them in anyway.
|
||||||
|
|
||||||
|
A game is the first thing you write for yourself. Everything else in this firmware answers a need: a clock to set, a packet to decode, a file to move. A game answers nothing. It is there because the machine is yours and you wanted to play on it, and a device that has one feels owned in a way that a device with a perfect settings menu doesn't.
|
||||||
|
|
||||||
|
{{ figure(src="launcher.png", alt="The Cardputer's Launcher at 2x: a grid of twelve cyan icons on black, four across and three down. The tenth, a game pad, is on a filled cyan tile, and the name under the grid reads Games.", width=480, height=270, caption="The Launcher, full: twelve tiles, and the game pad is one of them.") }}
|
||||||
|
|
||||||
|
## What these three carry
|
||||||
|
|
||||||
|
**Pong** is from 1972 and had no program at all. The first machines were wired from logic chips: no processor, nothing to load. Two paddles, a square ball, a score. It is still the clearest picture there is of what a video game is.
|
||||||
|
|
||||||
|
**Tetris** was written in 1984 on a Soviet computer whose terminal could not draw, so the first blocks were pairs of brackets. Five years later it came in the box with the Game Boy, and with it came a folk tune from 1861 that half the planet now calls "the Tetris music".
|
||||||
|
|
||||||
|
**Snake** shipped on a Nokia phone in 1997, on a screen 84 pixels wide, and turned waiting rooms and bus stops into something else for a decade.
|
||||||
|
|
||||||
|
You don't have to have owned any of those machines for these games to pull at something. The sound of a line going, the snake one cell from its own tail, the ball coming back faster than you sent it: they are in the hands more than in the head. Nobody has to be told the rules of any of the three. That is rare enough in software to be worth an evening.
|
||||||
|
|
||||||
|
And they are small. That is the part I can't get over. This device has a screen of 240 by 135 pixels, somewhat more than the Game Boy's 160 by 144, and 8 MB of flash where the Game Boy's Tetris had 32 KB to itself. Three games fit here in less than half of that cartridge. They were small when memory cost a fortune, and they stayed small because there was nothing in them to grow: a field, a rule, a clock that speeds up.
|
||||||
|
|
||||||
|
{{ figure(src="games.png", alt="The Games App at 2x: three icons in a row on black. Two paddles and a ball; a snake with a head, on a filled cyan tile; stacked blocks over a floor, with a small orange dot at its corner. Under the row, Snake in bold, and below it, best 11.", width=480, height=270, caption="The three games, shown the way the Launcher shows Apps. The orange dot says a game of Tetris is waiting, paused. Best 11 is mine, by hand.") }}
|
||||||
|
|
||||||
|
## Seventeen questions, all answered "Ok"
|
||||||
|
|
||||||
|
The design round was seventeen questions, each with a recommended answer, and I took every recommendation. It went fast because the facts had been looked up first, and two of them shaped everything:
|
||||||
|
|
||||||
|
**The Launcher was full.** Its grid shows twelve Apps and eleven were there. So: one Games App holding three games. In the project's vocabulary a **Game** is not an App. It has no tile, and it is found again paused when you leave in the middle of one.
|
||||||
|
|
||||||
|
**Apps only heard key presses.** A key gave one event when it went down, and nothing while it stayed down. A Pong paddle moved by tapping is not Pong. The key mapper now answers "is this key held?", and three tests on the PC hold keys down to prove it.
|
||||||
|
|
||||||
|
The rest, briefly:
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| | Decided |
|
||||||
|
|---|---|
|
||||||
|
| Pong | Against the device, first to 7. Its paddle has a top speed, so a steep fast ball beats it |
|
||||||
|
| Snake | 38 by 19 cells. The walls kill |
|
||||||
|
| Tetris | 10 by 20, upright, 6 pixels a cell. Pieces dealt from a bag of seven, the next one shown |
|
||||||
|
| Difficulty | No choice. Each game starts gently and speeds up |
|
||||||
|
| Colours | The seven pieces take the theme's seven accent colours |
|
||||||
|
| Scores | One best for each game, kept in the device |
|
||||||
|
| Sound | Short beeps. **No music** |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
Each game lives in `lib/games` as a state machine that knows nothing about a screen or a keyboard: so many milliseconds and these keys go in, the new state and what happened (a bounce, a line) come out. A seed decides the dice, so the same seed is the same game. That is what lets a test say "a player who always meets the ball must take points off the device over five games", and have it checked on every build without anyone playing.
|
||||||
|
|
||||||
|
{{ figure(src="pong.png", alt="Pong on the Cardputer at 2x: a cyan paddle on the left near the top, an orange one on the right, a dashed line down the middle, a small white ball left of centre, and the score 1 to 0 at the top.", width=480, height=270, caption="One-nil against the device. The cyan paddle is the player's.") }}
|
||||||
|
|
||||||
|
## Then I played it
|
||||||
|
|
||||||
|
A design round answers the questions you can think of from a chair. Then the thing is in your hands, and in a little over an hour I had asked for eight changes:
|
||||||
|
|
||||||
|
1. **A list of three games looked wrong.** The Launcher shows icons; so should this. Three icons, drawn on a 16 by 16 grid and doubled, like the Apps'. The first snake looked like the digit 2 and was redrawn with a head.
|
||||||
|
2. **Space was dangerous.** On this keyboard it sits next to `/`, which is the arrow for "right". A hard drop can't be taken back, and one slip of a finger would do it. It is on `1` now, far from the arrows.
|
||||||
|
3. **"No music" lasted until Tetris was running.** I changed my mind and asked for the tune. See below.
|
||||||
|
4. **`8` and `9`** for the music's volume, once there was music.
|
||||||
|
5. **The tune speeds up with the level,** because the fall does.
|
||||||
|
6. **The top volume was too low.** The speaker had only ever been used for short beeps, at a quarter of what it can do.
|
||||||
|
7. **Then it was too high,** and the scale was cut to four fifths.
|
||||||
|
8. **The soft drop sometimes behaved like a hard drop.** That one was a bug, and is under "What went wrong".
|
||||||
|
|
||||||
|
None of these would have come out of more questions. They came out of having the thing in my hands.
|
||||||
|
|
||||||
|
{{ figure(src="tetris.png", alt="Tetris on the Cardputer at 2x: a well in the middle, with a pile of blocks in seven colours filling its lower half except the rightmost column, and a yellow square piece falling. On the left, Score 0, Best 56, Lines 0. On the right, Next with a long cyan piece, Level 1, and at the bottom, music 2/8.", width=480, height=270, caption="All seven colours, from the theme. The score is 0 because the script that played this let every piece fall by itself: a drop scores, and I didn't want a script's score kept as the best. The 56 is from a test.") }}
|
||||||
|
|
||||||
|
## The tune is 232 bytes
|
||||||
|
|
||||||
|
Korobeiniki is a song about a pedlar, from a poem of 1861, and it has been the sound of Tetris since the Game Boy. Here it is a table of 58 notes, each a frequency and a length in eighths, four bytes a note: the first part, played twice, then the slow second part. A player the size of a paragraph says when the next note starts, and the speaker's tone generator does the rest. There is no sample, no synthesiser and no file.
|
||||||
|
|
||||||
|
It has a speaker channel of its own, so the beep of a line cleared doesn't cut a note short. At the first level it runs at 150 beats a minute, and each level takes 8 milliseconds off every eighth, down to about 270 beats a minute from the thirteenth level on. A new pace starts at the next note, never in the middle of one. The timing has its own tests: 9.9 seconds fed in steps of 33 ms must give exactly a hundred notes, not ninety-nine and a drift.
|
||||||
|
|
||||||
|
`0` switches it off, and the device remembers.
|
||||||
|
|
||||||
|
## What went wrong
|
||||||
|
|
||||||
|
**The soft drop that wouldn't let go.** Holding `.` makes a piece fall faster. When it landed with the key still down, the next piece came in already falling fast, and so did the one after: a soft drop that felt like a hard one. Now the key that brought a piece down does nothing for the next piece until it is let go and pressed again. It was also simply too fast, a row every 50 ms; it is 90 now.
|
||||||
|
|
||||||
|
**A dead snake was a cell short.** The tail was moved before checking whether the head had hit something, so the snake on the "Game over" screen was missing its last cell. One cell, and it looked wrong at once.
|
||||||
|
|
||||||
|
**The pictures in this post tore.** The Debug Console's screenshot reads the frame while the App may be drawing it. For a settings page that never matters. A game redraws thirty times a second, and the first Tetris picture had a black band through the pile where a new frame was half drawn. The code has said so in a comment since the day it was written: "it can tear; it's for looking". The picture above is one of two taken back to back that agreed.
|
||||||
|
|
||||||
|
**A script is bad at Snake.** I wanted a picture of a snake that had eaten something, and the console can press keys, so a script read the screen and steered. It lost 24 games in under five minutes and never got longer than six. The console goes quiet for a second or two now and then, and at a cell every 150 ms that is a wall. It went quiet most often right after the beep of a meal. With the sound off it lost 11 games in two and a half minutes and reached ten cells, which is the snake below. I haven't found out why a beep and a stalled connection go together.
|
||||||
|
|
||||||
|
{{ figure(src="snake.png", alt="Snake on the Cardputer at 2x: a framed black field, a green snake ten cells long with a white head, folded back on itself in the middle, and an orange square of food near the bottom right corner. Above the field, the score 7 on the left and best 11 on the right.", width=480, height=270, caption="Ten cells, seven meals, played by a script over Wi-Fi, a beat late. The best of 11 it didn't touch.") }}
|
||||||
|
|
||||||
|
## What I checked, and what I didn't
|
||||||
|
|
||||||
|
Checked on the device, by playing: all three games start, pause, resume and end; the paddle follows a held key; Tetris's left and right repeat; the tune, its switch, its volume and its memory of both; the screen staying lit during a game and dimming again when one is paused.
|
||||||
|
|
||||||
|
Checked on the PC, by 44 tests: the rules. Bounces, the first to seven, the device's paddle having a top speed and still losing points; turning back on itself not being a turn, following its own tail being allowed; the bag dealing each piece once in seven, a turn against a wall shifting the piece, four rows at once scoring 800.
|
||||||
|
|
||||||
|
Not checked: Tetris past the first few levels, so nobody has heard the tune at its fastest. A long session on battery, with the screen redrawn thirty times a second. Whether the top of the volume scale distorts on every unit or only on mine. And in the Nord theme two of the seven pieces come out the same orange, because that theme's red and orange are neighbours on a screen with 256 colours.
|
||||||
|
|
||||||
|
## Also since last time
|
||||||
|
|
||||||
|
There was no post for the last three releases, and v0.24.0 has more in it than games:
|
||||||
|
|
||||||
|
- **An SSH client** (**v0.22.0**): a real terminal on another machine, with `vim` and `top` in it, and `scp` from the Shell (**v0.22.1**).
|
||||||
|
- **MeshCore in the LoRa Scanner, a Launcher of icons, seven colour themes, Settings in groups** (**v0.23.0**).
|
||||||
|
- Also in **v0.24.0**: **a compass** in the GNSS App, which has no magnetometer and takes north from walking or from the sun; **updates that install themselves** after a 30-second question, if you ask for that; and **downloads that carry on** after the Wi-Fi drops instead of starting over.
|
||||||
|
|
||||||
|
## By the numbers
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Games | 3 |
|
||||||
|
| Flash | 14,616 bytes |
|
||||||
|
| Static memory | 8 bytes |
|
||||||
|
| Lines of game rules, `lib/games` | 719 |
|
||||||
|
| Tests that play them | 44 |
|
||||||
|
| The tune | 58 notes, 232 bytes |
|
||||||
|
| Its pace | 150 to 272 beats a minute |
|
||||||
|
| Pictures a second while a game runs | 30 |
|
||||||
|
| Design questions | 17 |
|
||||||
|
| Answered "Ok" | 17 |
|
||||||
|
| Answers overturned by playing | 3 |
|
||||||
|
| Games of Snake lost by a script, in the two runs counted | 35 |
|
||||||
|
| Host tests | 673 |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
## Where it stands
|
||||||
|
|
||||||
|
{% steps() %}
|
||||||
|
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
|
||||||
|
|
||||||
|
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
|
||||||
|
|
||||||
|
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
|
||||||
|
|
||||||
|
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
|
||||||
|
|
||||||
|
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
|
||||||
|
|
||||||
|
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
|
||||||
|
|
||||||
|
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
|
||||||
|
|
||||||
|
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
|
||||||
|
|
||||||
|
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
|
||||||
|
|
||||||
|
10. ~~Pictures, a screenshot key, the card in a phone's browser.~~ v0.16.0 to v0.18.0, [Press w](/devlog/roro9stack-share/).
|
||||||
|
|
||||||
|
11. ~~A WireGuard tunnel, and nine commands for a bad network day.~~ v0.19.0 to v0.21.0, [Six releases behind](/devlog/roro9stack-vpn/).
|
||||||
|
|
||||||
|
12. ~~SSH, MeshCore, icons and themes.~~ v0.22.0 to v0.23.0.
|
||||||
|
|
||||||
|
13. ~~A compass, updates that install themselves, and Pong, Snake and Tetris.~~ v0.24.0, this post.
|
||||||
|
|
||||||
|
14. Next: M4, the mesh, which still wants a second node.
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
{% signoff() %}
|
||||||
|
Fourteen thousand bytes, three games, one tune. Most of what this firmware does took measuring, and bit me first. This took an evening and asked for almost nothing. The device could already do a great deal. Now it is also something to pick up when there is nothing to do, and that was the missing feature.
|
||||||
|
{% end %}
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,12 +1,12 @@
|
|||||||
+++
|
+++
|
||||||
title = "Games"
|
title = "Games"
|
||||||
description = "Pong, Snake and Tetris, for when the radio is quiet."
|
description = "Pong, Snake, Tetris, Minesweeper, 2048 and Asteroids, for when the radio is quiet."
|
||||||
weight = 17
|
weight = 17
|
||||||
[extra]
|
[extra]
|
||||||
tag = "Games"
|
tag = "Games"
|
||||||
+++
|
+++
|
||||||
|
|
||||||
The Games App opens on its three games, an icon each, as the Launcher shows Apps: <kbd>,</kbd> and <kbd>/</kbd> go from one to the next, with its name and your best so far under the row. <kbd>Enter</kbd> picks one, <kbd>Enter</kbd> again starts it.
|
The Games App opens on its six games, an icon each, as the Launcher shows Apps: <kbd>,</kbd> and <kbd>/</kbd> go from one to the next, with its name and your best so far under the row. <kbd>Enter</kbd> picks one, <kbd>Enter</kbd> again starts it.
|
||||||
|
|
||||||
In every game:
|
In every game:
|
||||||
|
|
||||||
@@ -14,14 +14,16 @@ In every game:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| <kbd>Enter</kbd> | Starts, or starts again when a game is over |
|
| <kbd>Enter</kbd> | Starts, or starts again when a game is over |
|
||||||
| <kbd>p</kbd> | Pauses. Any key carries on |
|
| <kbd>p</kbd> | Pauses. Any key carries on |
|
||||||
| <kbd>`</kbd> | Back to the three games. This one waits, paused |
|
| <kbd>`</kbd> | Back to the other games. This one waits, paused |
|
||||||
|
|
||||||
A game you leave in the middle, for another game or another App, is **found again paused**, and its icon has an orange dot. While a game is being played the screen does not dim. The games beep, and Tetris has its tune, unless **Settings → This device → Sound & LED** is off. There is no choice of difficulty: each game starts gently and gets faster.
|
A game you leave in the middle, for another game or another App, is **found again paused**, and its icon has an orange dot. While Pong, Snake, Tetris or Asteroids is being played the screen does not dim. The games beep, and Tetris has its tune, unless **Settings → This device → Sound & LED** is off. There is no choice of difficulty: the games on a clock start gently and get faster, and the other two are always the same size.
|
||||||
|
|
||||||
## Pong
|
## Pong
|
||||||
|
|
||||||
You have the left paddle, the device the right one. Hold <kbd>;</kbd> to go up and <kbd>.</kbd> to go down. **First to 7 wins.**
|
You have the left paddle, the device the right one. Hold <kbd>;</kbd> to go up and <kbd>.</kbd> to go down. **First to 7 wins.**
|
||||||
|
|
||||||
|
Or steer by **tilting the device**: <kbd>0</kbd> switches between the keys and tilting, and the device remembers which. Tilted, the paddle rolls to the low side: lower the top edge and the paddle goes up, raise it and it goes down, about 15 degrees either way for the whole height. However you hold it when the game starts, or when you switch, is the middle. If that feels upside down, <kbd>9</kbd> turns it round (raise the top edge to go up), and that is remembered too. The keys still work while tilting. The top left corner says `keys`, `tilt` or `tilt, inverted`.
|
||||||
|
|
||||||
Where the ball meets your paddle decides how it leaves: off the middle it goes straight, off an end it goes steeply. Each return makes the ball a little faster. The device's paddle only moves so fast: a steep, fast ball gets past it.
|
Where the ball meets your paddle decides how it leaves: off the middle it goes straight, off an end it goes steeply. Each return makes the ball a little faster. The device's paddle only moves so fast: a steep, fast ball gets past it.
|
||||||
|
|
||||||
What is kept is your **longest rally**: the most returns you made between two serves.
|
What is kept is your **longest rally**: the most returns you made between two serves.
|
||||||
@@ -49,8 +51,41 @@ It plays to **Korobeiniki**, the tune Tetris has always had, round and round, an
|
|||||||
|
|
||||||
A full row goes: 100 points for one, 300 for two, 500 for three, **800 for four at once**, times the level. Falling faster gives a point a row, dropping two. The key that brought a piece down does not send the next one down too: let it go and press again. Every ten rows is a level, and the fall quickens.
|
A full row goes: 100 points for one, 300 for two, 500 for three, **800 for four at once**, times the level. Falling faster gives a point a row, dropping two. The key that brought a piece down does not send the next one down too: let it go and press again. Every ten rows is a level, and the fall quickens.
|
||||||
|
|
||||||
|
## Minesweeper
|
||||||
|
|
||||||
|
A field of 20 by 9 cells hides **28 mines**, about one cell in seven. Open every cell that has none.
|
||||||
|
|
||||||
|
| Key | Does |
|
||||||
|
|---|---|
|
||||||
|
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | Move the cursor. Held, it runs along |
|
||||||
|
| <kbd>Enter</kbd> | Digs the cell under the cursor |
|
||||||
|
| <kbd>f</kbd> | Puts a flag on a cell you think is a mine, or takes it off |
|
||||||
|
|
||||||
|
A cell you open shows **how many mines touch it**, of the eight around; one that touches none opens its neighbours too, and so on. The **first dig is never a mine**, nor next to one, so it always opens something.
|
||||||
|
|
||||||
|
<kbd>Enter</kbd> on an open number whose mines are all flagged opens the rest of the cells around it at once. If a flag is wrong, that is a mine dug.
|
||||||
|
|
||||||
|
The top line says how many mines are left to flag and how long you have been at it; the clock starts at the first dig. Dig a mine and the field shows where they all were, with the wrong flags gone pale. What is kept is your **fastest cleared field**.
|
||||||
|
|
||||||
|
## 2048
|
||||||
|
|
||||||
|
The arrows slide every tile on the 4 by 4 board as far as it goes. **Two tiles alike that meet become one of twice the value**, and after each move a new 2 (sometimes a 4) appears. The score is the sum of every tile you made.
|
||||||
|
|
||||||
|
Reaching **2048** is said once, and the game goes on for as long as a move is left. There is nothing to pause: leave it and come back, and the board is as you left it.
|
||||||
|
|
||||||
|
## Asteroids
|
||||||
|
|
||||||
|
You steer the ship by **leaning the device**: lean it to the right and the ship turns clockwise, to the left and it turns the other way, faster the further you lean. However you hold it when the game starts counts as straight on, so there is no need to hold it flat. <kbd>,</kbd> and <kbd>/</kbd> held turn it too, for a device lying on a table.
|
||||||
|
|
||||||
|
| Key | Does |
|
||||||
|
|---|---|
|
||||||
|
| <kbd>;</kbd> | Thrust, while held. The ship keeps drifting when you let go, slowing a little |
|
||||||
|
| <kbd>.</kbd> | Fires. Four shots at most at a time, and they don't go far |
|
||||||
|
|
||||||
|
Everything wraps round: what leaves at one edge comes back at the other. A large rock breaks in two, each of those in two again, and the small ones are gone: 20, 50 and 100 points. Clear the field and the next wave has one rock more. You have three ships; a new one comes in the middle, blinking, and nothing can touch it for two seconds.
|
||||||
|
|
||||||
## The keys, as the device lists them
|
## The keys, as the device lists them
|
||||||
|
|
||||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||||
|
|
||||||
{{ keys(scopes=["games", "games-pong", "games-snake", "games-tetris"]) }}
|
{{ keys(scopes=["games", "games-pong", "games-snake", "games-tetris", "games-mines", "games-2048", "games-asteroids"]) }}
|
||||||
|
|||||||
@@ -1,72 +1,133 @@
|
|||||||
# The App cards on the home page. Each says what the firmware does today (docs/milestones/W1.md, Q185):
|
# The Apps, in the Launcher's order: the Apps page shows them all, the home page those marked
|
||||||
# what isn't built is marked planned, with its milestone. `icon` is a cell of static/img/icons-*.svg.
|
# `featured`. Each says what the firmware does today (docs/milestones/W1.md, Q185); what isn't
|
||||||
|
# built is marked planned, with its milestone. `icon` is a cell of static/img/icons-*.svg (three
|
||||||
|
# across: 0 planned, 1 LoRa, 2 GNSS, 3 Wi-Fi, 4 Gemini, 5 IRC, 6 Notes, 7 System, 8 Storage,
|
||||||
|
# 9 Settings, 10 SSH, 11 Shell, 12 Games). `screen` is a file in static/screens/, a screenshot
|
||||||
|
# from the device at 2x; an App without one has none.
|
||||||
|
|
||||||
[[app]]
|
[[app]]
|
||||||
num = "01"
|
tag = "IRC"
|
||||||
tag = "LoRa Scanner"
|
title = "Chat from the keyboard"
|
||||||
title = "See what is on the air"
|
text = "Join channels and type commands as you would anywhere, with a log a day written to the SD card. It stays connected when you leave the App, and the Launcher marks unread messages."
|
||||||
text = "Lists every packet it hears with time, RSSI, SNR, sender and hops, reading the Meshtastic or MeshCore header. A Sweep shows signal strength from 863 to 870 MHz. Capture to a pcap for Wireshark."
|
fact = "Logs to SD"
|
||||||
fact = "Only listens, never transmits"
|
icon = 5
|
||||||
icon = 1
|
guide = "/guide/irc/"
|
||||||
|
|
||||||
|
[[app]]
|
||||||
|
tag = "Wi-Fi Tools"
|
||||||
|
title = "Scan the air for networks"
|
||||||
|
text = "The networks around you, sorted and filtered; how busy each channel is; a tracker that follows one network's signal as you walk. Scans log to a CSV file on the SD card."
|
||||||
|
fact = "Scans log to CSV"
|
||||||
|
icon = 3
|
||||||
|
guide = "/guide/wifi-tools/"
|
||||||
|
|
||||||
[[app]]
|
[[app]]
|
||||||
num = "02"
|
|
||||||
tag = "GNSS"
|
tag = "GNSS"
|
||||||
title = "Find your place in the sky"
|
title = "Find your place, and your way"
|
||||||
text = "Position and Sky views show where you are and which satellites you can see. Tracks save to the SD card as GPX, and the clock follows the fix."
|
text = "Position, a Sky view of every satellite, and a Compass that leads back to a place you marked. Tracks save to the SD card as GPX, and the clock follows the fix."
|
||||||
fact = "Sky view and GPX Tracks"
|
fact = "No magnetometer: north from walking, or the sun"
|
||||||
icon = 2
|
icon = 2
|
||||||
|
guide = "/guide/gnss/"
|
||||||
|
screen = "apps/gnss.png"
|
||||||
|
alt = "The GNSS App's sky view: a circle with the satellites of four constellations as coloured dots, filled when used, and a legend counting them: GPS 6 of 12, GLONASS 3 of 4, Galileo 2 of 2, BeiDou 3 of 3, 3D Fix"
|
||||||
|
featured = true
|
||||||
|
|
||||||
[[app]]
|
[[app]]
|
||||||
num = "03"
|
|
||||||
tag = "Gemini"
|
tag = "Gemini"
|
||||||
title = "Browse Geminispace"
|
title = "Browse Geminispace"
|
||||||
text = "Follow links with Tab and Enter, go Back to where you were, bookmark pages and save them to the SD card to read offline."
|
text = "Follow links with Tab and Enter, go Back to where you were, bookmark pages and save them to the SD card to read offline."
|
||||||
fact = "Certificates trusted on first use"
|
fact = "Certificates trusted on first use"
|
||||||
icon = 4
|
icon = 4
|
||||||
|
guide = "/guide/gemini/"
|
||||||
|
screen = "apps/gemini.png"
|
||||||
|
alt = "The Gemini App's start page: Gemini, your bookmarks and Saved Pages, then a few places to start, and a saved page from bbs.geminispace.org"
|
||||||
|
featured = true
|
||||||
|
|
||||||
[[app]]
|
[[app]]
|
||||||
num = "04"
|
tag = "LoRa Scanner"
|
||||||
tag = "IRC"
|
title = "See what is on the air"
|
||||||
title = "Chat from the keyboard"
|
text = "Lists every packet it hears with its signal, and reads the Meshtastic and MeshCore headers, and MeshCore's public channel. A Sweep shows signal strength from 863 to 870 MHz. Capture to a pcap for Wireshark."
|
||||||
text = "Join channels and type commands as you would anywhere, with daily logs written to the SD card."
|
fact = "Only listens, never transmits"
|
||||||
fact = "Logs to SD"
|
icon = 1
|
||||||
icon = 5
|
guide = "/guide/lora-scanner/"
|
||||||
|
screen = "apps/lora.png"
|
||||||
|
alt = "The LoRa Scanner's Sweep: bars of signal strength from 863.0 to 870.0 MHz with peak marks, a waterfall under them, and a footer reading floor -100, 866.3 MHz at -88 dBm"
|
||||||
|
featured = true
|
||||||
|
|
||||||
[[app]]
|
[[app]]
|
||||||
num = "05"
|
|
||||||
tag = "Wi-Fi tools"
|
|
||||||
title = "Scan the air for networks"
|
|
||||||
text = "See the networks around you, sort and filter the list, and log scans to a CSV file on the SD card."
|
|
||||||
fact = "Scans log to CSV"
|
|
||||||
icon = 3
|
|
||||||
|
|
||||||
[[app]]
|
|
||||||
num = "06"
|
|
||||||
tag = "Notes"
|
|
||||||
title = "Write it down"
|
|
||||||
text = "Keep plain text notes on the SD card, of any size: a megabyte opens as fast as a line. There is no save key: the editor writes five seconds after you stop typing, so a power cut never costs the note."
|
|
||||||
fact = "Plain .txt files in /notes"
|
|
||||||
icon = 6
|
|
||||||
|
|
||||||
[[app]]
|
|
||||||
num = "07"
|
|
||||||
tag = "Storage"
|
tag = "Storage"
|
||||||
title = "Manage the SD card"
|
title = "Manage the SD card"
|
||||||
text = "Browse, copy, cut, rename and delete with a clipboard. Opens text, pictures, hex, captures, tracks and update files. Press w and the card is a web page in your phone's browser: upload and download with nothing to install."
|
text = "Browse, copy, cut, rename and delete with a clipboard. Opens text, pictures, hex, captures, tracks and update files. Press w and the card is a web page in your phone's browser: upload and download with nothing to install."
|
||||||
fact = "Checks every copy by size"
|
fact = "Checks every copy by size"
|
||||||
icon = 8
|
icon = 8
|
||||||
|
guide = "/guide/storage/"
|
||||||
|
screen = "apps/storage.png"
|
||||||
|
alt = "The Storage App at the top of the SD card: the folders captures, gemini, gnss, irc, notes, pictures, screenshots and system, with 15 GB free"
|
||||||
|
featured = true
|
||||||
|
|
||||||
|
[[app]]
|
||||||
|
tag = "Notes"
|
||||||
|
title = "Write it down"
|
||||||
|
text = "Keep plain text notes on the SD card, of any size: a megabyte opens as fast as a line. There is no save key: the editor writes five seconds after you stop typing, so a power cut never costs the note."
|
||||||
|
fact = "Plain .txt files in /notes"
|
||||||
|
icon = 6
|
||||||
|
guide = "/guide/notes/"
|
||||||
|
screen = "apps/notes.png"
|
||||||
|
alt = "The Notes App listing the notes in /notes, each under its first line, with its date"
|
||||||
|
|
||||||
|
[[app]]
|
||||||
|
tag = "SSH"
|
||||||
|
title = "A terminal on another machine"
|
||||||
|
text = "Log in to a server with a password or with the device's own key, and run a shell, vim or top on the Cardputer's screen, in sixteen colours. Files go with scp, from the Shell."
|
||||||
|
fact = "The session stays open when you leave"
|
||||||
|
icon = 10
|
||||||
|
guide = "/guide/ssh/"
|
||||||
|
screen = "apps/ssh.png"
|
||||||
|
alt = "The SSH App: a host connected to before, then New connection and This device's key"
|
||||||
|
featured = true
|
||||||
|
|
||||||
|
[[app]]
|
||||||
|
tag = "Shell"
|
||||||
|
title = "The console, on the device"
|
||||||
|
text = "Every command the firmware's console knows, typed on its own keyboard: the card's files, ping, nslookup, traceroute, a certificate check, the radio and the GNSS receiver. Tab completes."
|
||||||
|
fact = "The same commands as over USB"
|
||||||
|
icon = 11
|
||||||
|
guide = "/guide/shell/"
|
||||||
|
screen = "apps/shell.png"
|
||||||
|
alt = "The Shell after uname -a: roro9stack, its version, xtensa-lx7 esp32s3, and an empty line to type the next command"
|
||||||
|
|
||||||
|
[[app]]
|
||||||
|
tag = "Games"
|
||||||
|
title = "For when the radio is quiet"
|
||||||
|
text = "Pong, Snake and Tetris with its tune, Minesweeper, 2048, and Asteroids steered by leaning the device. Pong can be steered by tilting too."
|
||||||
|
fact = "The best of each is kept"
|
||||||
|
icon = 12
|
||||||
|
guide = "/guide/games/"
|
||||||
|
screen = "apps/games.png"
|
||||||
|
alt = "The Games App: six icons in a row, Pong chosen on a filled tile, and under them Pong and its longest rally"
|
||||||
|
featured = true
|
||||||
|
|
||||||
[[app]]
|
[[app]]
|
||||||
num = "08"
|
|
||||||
tag = "System"
|
tag = "System"
|
||||||
title = "Watch it think"
|
title = "Watch it think"
|
||||||
text = "Per-core load, every task and its spare stack, free heap, network traffic, battery and chip temperature, live."
|
text = "Per-core load, every task and its spare stack, free heap, network traffic, battery and chip temperature, live."
|
||||||
fact = "Live and read-only"
|
fact = "Live and read-only"
|
||||||
icon = 7
|
icon = 7
|
||||||
|
guide = "/guide/system/"
|
||||||
|
screen = "apps/system.png"
|
||||||
|
alt = "The System App's overview: the load of both cores, 84 KB of memory free, the network's traffic, the battery at 99 percent, uptime and temperature, and a graph"
|
||||||
|
|
||||||
|
[[app]]
|
||||||
|
tag = "Settings"
|
||||||
|
title = "Make it yours"
|
||||||
|
text = "The device's name and region, brightness and seven colour themes, GNSS and the radio, Wi-Fi networks and a WireGuard VPN, firmware updates and the Debug Console, in five groups."
|
||||||
|
fact = "Every theme dark and light"
|
||||||
|
icon = 9
|
||||||
|
guide = "/guide/settings/"
|
||||||
|
screen = "apps/settings.png"
|
||||||
|
alt = "Settings, in five groups: This device, Display, GNSS and radio, Network and System"
|
||||||
|
|
||||||
[[app]]
|
[[app]]
|
||||||
num = "09"
|
|
||||||
tag = "Planned"
|
tag = "Planned"
|
||||||
title = "A mesh messenger"
|
title = "A mesh messenger"
|
||||||
text = "Sending and reading messages on a Meshtastic-compatible mesh. The radio listens today, in the LoRa Scanner; transmitting is the next step and waits for a second node to test with."
|
text = "Sending and reading messages on a Meshtastic-compatible mesh. The radio listens today, in the LoRa Scanner; transmitting is the next step and waits for a second node to test with."
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ rows = [
|
|||||||
|
|
||||||
[[scope]]
|
[[scope]]
|
||||||
id = "games"
|
id = "games"
|
||||||
title = "Games, the three games"
|
title = "Games, the games"
|
||||||
rows = [
|
rows = [
|
||||||
["Enter", "this game"],
|
["Enter", "this game"],
|
||||||
[", /", "the game before, the next"],
|
[", /", "the game before, the next"],
|
||||||
@@ -398,6 +398,8 @@ id = "games-pong"
|
|||||||
title = "Games, Pong"
|
title = "Games, Pong"
|
||||||
rows = [
|
rows = [
|
||||||
["; .", "the paddle, while held"],
|
["; .", "the paddle, while held"],
|
||||||
|
["0", "steer with the keys, or by tilting"],
|
||||||
|
["9", "tilting the other way up"],
|
||||||
["Enter", "start, or start again"],
|
["Enter", "start, or start again"],
|
||||||
["p", "pause; any key carries on"],
|
["p", "pause; any key carries on"],
|
||||||
["`", "the other games; this one waits"],
|
["`", "the other games; this one waits"],
|
||||||
@@ -429,6 +431,39 @@ rows = [
|
|||||||
["`", "the other games; this one waits"],
|
["`", "the other games; this one waits"],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[scope]]
|
||||||
|
id = "games-mines"
|
||||||
|
title = "Games, Minesweeper"
|
||||||
|
rows = [
|
||||||
|
["; . , /", "move; held, it runs"],
|
||||||
|
["Enter", "dig; on a number, the rest"],
|
||||||
|
["f", "a flag, on or off"],
|
||||||
|
["p", "pause; any key carries on"],
|
||||||
|
["`", "the other games; this one waits"],
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scope]]
|
||||||
|
id = "games-2048"
|
||||||
|
title = "Games, 2048"
|
||||||
|
rows = [
|
||||||
|
["; . , /", "slide every tile that way"],
|
||||||
|
["Enter", "start, or start again"],
|
||||||
|
["`", "the other games; this one waits"],
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scope]]
|
||||||
|
id = "games-asteroids"
|
||||||
|
title = "Games, Asteroids"
|
||||||
|
rows = [
|
||||||
|
["lean", "turn: left or right"],
|
||||||
|
[", /", "turn, while held"],
|
||||||
|
[";", "thrust, while held"],
|
||||||
|
[".", "fire"],
|
||||||
|
["Enter", "start, or start again"],
|
||||||
|
["p", "pause; any key carries on"],
|
||||||
|
["`", "the other games; this one waits"],
|
||||||
|
]
|
||||||
|
|
||||||
[[scope]]
|
[[scope]]
|
||||||
id = "ssh"
|
id = "ssh"
|
||||||
title = "SSH, the hosts"
|
title = "SSH, the hosts"
|
||||||
|
|||||||
@@ -142,11 +142,32 @@ section { padding-top: 64px; padding-bottom: 64px; }
|
|||||||
.card .fact::before { content: ""; width: 8px; height: 8px; background: var(--cyan); flex: none; }
|
.card .fact::before { content: ""; width: 8px; height: 8px; background: var(--cyan); flex: none; }
|
||||||
.card.planned .tag { color: var(--orangetext); }
|
.card.planned .tag { color: var(--orangetext); }
|
||||||
.card.planned .fact::before { background: var(--orange); }
|
.card.planned .fact::before { background: var(--orange); }
|
||||||
.ic { width: 48px; height: 48px; flex: none; background-image: var(--icons); background-repeat: no-repeat; background-size: 144px 192px; image-rendering: pixelated; }
|
.ic { width: 48px; height: 48px; flex: none; background-image: var(--icons); background-repeat: no-repeat; background-size: 144px 288px; image-rendering: pixelated; }
|
||||||
.i0 { background-position: 0 0; } .i1 { background-position: -48px 0; } .i2 { background-position: -96px 0; }
|
.i0 { background-position: 0 0; } .i1 { background-position: -48px 0; } .i2 { background-position: -96px 0; }
|
||||||
.i3 { background-position: 0 -48px; } .i4 { background-position: -48px -48px; } .i5 { background-position: -96px -48px; }
|
.i3 { background-position: 0 -48px; } .i4 { background-position: -48px -48px; } .i5 { background-position: -96px -48px; }
|
||||||
.i6 { background-position: 0 -96px; } .i7 { background-position: -48px -96px; } .i8 { background-position: -96px -96px; }
|
.i6 { background-position: 0 -96px; } .i7 { background-position: -48px -96px; } .i8 { background-position: -96px -96px; }
|
||||||
.i9 { background-position: 0 -144px; }
|
.i9 { background-position: 0 -144px; } .i10 { background-position: -48px -144px; } .i11 { background-position: -96px -144px; }
|
||||||
|
.i12 { background-position: 0 -192px; }
|
||||||
|
/* The Apps page: an App a row, its words beside a screen from the device. */
|
||||||
|
.apps { display: flex; flex-direction: column; gap: 24px; margin-top: 40px; }
|
||||||
|
.app { background: var(--s2); padding: 32px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 400px); gap: 32px; align-items: center; scroll-margin-top: 96px; }
|
||||||
|
.app-text { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||||
|
.app .card-top { justify-content: flex-start; gap: 16px; }
|
||||||
|
.app .tag { font: 500 12px/16px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); }
|
||||||
|
.app h2 { font-size: 24px; line-height: 28px; }
|
||||||
|
.app h2 a { color: inherit; text-decoration: none; }
|
||||||
|
.app h2 a:hover, .app h2 a:focus-visible { text-decoration: underline; text-underline-offset: 4px; }
|
||||||
|
.app p { color: var(--muted); }
|
||||||
|
.app .fact { display: flex; align-items: center; gap: 8px; font: 500 12px/16px var(--mono); letter-spacing: .04em; }
|
||||||
|
.app .fact::before { content: ""; width: 8px; height: 8px; background: var(--cyan); flex: none; }
|
||||||
|
.app.planned .tag { color: var(--orangetext); }
|
||||||
|
.app.planned .fact::before { background: var(--orange); }
|
||||||
|
.app-guide { font-size: 14px; }
|
||||||
|
.app-shot { margin: 0; }
|
||||||
|
.app-shot img { display: block; width: 100%; height: auto; }
|
||||||
|
.cards-more { margin-top: 24px; }
|
||||||
|
.card h3 a { color: inherit; text-decoration: none; }
|
||||||
|
.card h3 a:hover, .card h3 a:focus-visible { text-decoration: underline; text-underline-offset: 4px; }
|
||||||
.cards-note { margin-top: 24px; font-size: 14px; line-height: 20px; color: var(--muted); }
|
.cards-note { margin-top: 24px; font-size: 14px; line-height: 20px; color: var(--muted); }
|
||||||
|
|
||||||
/* Real screenshots */
|
/* Real screenshots */
|
||||||
@@ -225,6 +246,7 @@ footer small { display: block; max-width: 760px; }
|
|||||||
section { padding-top: 48px; padding-bottom: 48px; }
|
section { padding-top: 48px; padding-bottom: 48px; }
|
||||||
.slab, .updates, .cta { padding: 24px; }
|
.slab, .updates, .cta { padding: 24px; }
|
||||||
.panel, .card { padding: 24px; }
|
.panel, .card { padding: 24px; }
|
||||||
|
.app { grid-template-columns: minmax(0, 1fr); padding: 24px; gap: 24px; }
|
||||||
.hero .caption { margin-top: -24px; }
|
.hero .caption { margin-top: -24px; }
|
||||||
.num-row .big { font-size: 48px; line-height: 48px; min-width: 120px; }
|
.num-row .big { font-size: 48px; line-height: 48px; min-width: 120px; }
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}The Apps: roro9stack{% endblock %}
|
||||||
|
{% block description %}{{ page.description }}{% endblock %}
|
||||||
|
{% block main %}
|
||||||
|
{% set apps = load_data(path="data/apps.toml", format="toml") %}
|
||||||
|
<div class="wrap page">
|
||||||
|
<header>
|
||||||
|
<span class="eyebrow">What's inside</span>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p class="lead">{{ page.description }} On the device, the Launcher shows them as a grid of icons; <kbd>Fn</kbd> + <kbd>h</kbd> lists the keys of any screen.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="apps">
|
||||||
|
{% for a in apps.app %}
|
||||||
|
<article class="app n-lg{% if a.planned %} planned{% endif %}" id="{{ a.tag | slugify }}">
|
||||||
|
<div class="app-text">
|
||||||
|
<div class="card-top"><div class="ic i{{ a.icon }}" aria-hidden="true"></div><span class="tag">{{ a.tag }}</span></div>
|
||||||
|
<h2>{% if a.guide %}<a href="{{ a.guide }}">{{ a.title }}</a>{% else %}{{ a.title }}{% endif %}</h2>
|
||||||
|
<p>{{ a.text }}</p>
|
||||||
|
<span class="fact">{{ a.fact }}</span>
|
||||||
|
{% if a.guide %}<a class="accent-link app-guide" href="{{ a.guide }}">Its page in the guide →</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if a.screen %}
|
||||||
|
<figure class="app-shot">
|
||||||
|
<img src="/screens/{{ a.screen | safe }}" width="480" height="270" alt="{{ a.alt }}" loading="lazy">
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<img class="logo logo-light" src="/img/logo-light.svg" alt="" width="201" height="28">
|
<img class="logo logo-light" src="/img/logo-light.svg" alt="" width="201" height="28">
|
||||||
</a>
|
</a>
|
||||||
<nav aria-label="Main">
|
<nav aria-label="Main">
|
||||||
<a href="/#apps">Apps</a>
|
<a href="/apps/">Apps</a>
|
||||||
<a href="/install/">Install</a>
|
<a href="/install/">Install</a>
|
||||||
<a href="/guide/">Guide</a>
|
<a href="/guide/">Guide</a>
|
||||||
<a href="/dev/">Developers</a>
|
<a href="/dev/">Developers</a>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<h1 id="hero-title">Open firmware for a <span class="accent">pocket computer.</span></h1>
|
<h1 id="hero-title">Open firmware for a <span class="accent">pocket computer.</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="lead">roro9stack is firmware for the M5Stack Cardputer ADV with the Cap LoRa-1262: a LoRa scanner that reads Meshtastic traffic, GNSS, IRC, a Gemini browser, Wi-Fi tools, notes and more.</p>
|
<p class="lead">roro9stack is firmware for the M5Stack Cardputer ADV with the Cap LoRa-1262: a LoRa scanner that reads Meshtastic and MeshCore traffic, GNSS with a compass, an SSH client, a Gemini browser, IRC, Wi-Fi tools, notes, games and more.</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-primary n-md" href="/install/">Install{% if latest %} {{ latest.tag_name }}{% endif %}</a>
|
<a class="btn btn-primary n-md" href="/install/">Install{% if latest %} {{ latest.tag_name }}{% endif %}</a>
|
||||||
<a class="btn btn-secondary n-md" href="{{ config.extra.repo }}">Read the source</a>
|
<a class="btn btn-secondary n-md" href="{{ config.extra.repo }}">Read the source</a>
|
||||||
@@ -55,20 +55,21 @@
|
|||||||
<section class="wrap" id="apps" aria-labelledby="apps-title">
|
<section class="wrap" id="apps" aria-labelledby="apps-title">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<span class="eyebrow">What's inside</span>
|
<span class="eyebrow">What's inside</span>
|
||||||
<h2 id="apps-title">Apps for the radio, the web and the card.</h2>
|
<h2 id="apps-title">Apps for the radio, the network, the card and the evening.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
{% for a in apps.app %}
|
{% for a in apps.app %}{% if a.featured %}
|
||||||
<article class="card n-lg{% if a.planned %} planned{% endif %}">
|
<article class="card n-lg">
|
||||||
<div class="ic i{{ a.icon }}" aria-hidden="true"></div>
|
<div class="ic i{{ a.icon }}" aria-hidden="true"></div>
|
||||||
<div class="card-top"><span class="tag">{{ a.tag }}</span><span class="num">{{ a.num }}</span></div>
|
<div class="card-top"><span class="tag">{{ a.tag }}</span></div>
|
||||||
<h3>{{ a.title }}</h3>
|
<h3><a class="card-link" href="/apps/#{{ a.tag | slugify }}">{{ a.title }}</a></h3>
|
||||||
<p>{{ a.text }}</p>
|
<p>{{ a.text }}</p>
|
||||||
<span class="fact">{{ a.fact }}</span>
|
<span class="fact">{{ a.fact }}</span>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endif %}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<p class="cards-note">Plus a <a class="accent-link" href="/guide/shell/">Shell</a> that runs the firmware's commands on the device, an <a class="accent-link" href="/guide/ssh/">SSH client</a> with a real terminal, a <a class="accent-link" href="/guide/vpn/">WireGuard VPN</a>, a screenshot key that works on every screen, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
|
<p class="cards-more"><a class="btn btn-secondary n-md" href="/apps/">All {{ apps.app | length - 1 }} Apps, with their screens</a></p>
|
||||||
|
<p class="cards-note">Also on every screen: a key that lists the keys, and a key that takes a screenshot. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="wrap" id="screens" aria-labelledby="screens-title">
|
<section class="wrap" id="screens" aria-labelledby="screens-title">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <M5Cardputer.h>
|
#include <M5Cardputer.h>
|
||||||
|
#include <M5Unified.h>
|
||||||
#include <esp_random.h>
|
#include <esp_random.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -21,8 +22,15 @@ namespace {
|
|||||||
constexpr uint32_t kFrameMs = 33; // 30 pictures a second while a Game runs
|
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 uint32_t kTapMs = 70; // how long a tap moves Pong's paddle
|
||||||
constexpr int kCell = 6; // Snake's and Tetris's cells, in pixels
|
constexpr int kCell = 6; // Snake's and Tetris's cells, in pixels
|
||||||
const char* const kNames[3] = {"Pong", "Snake", "Tetris"};
|
const char* const kNames[6] = {"Pong", "Snake", "Tetris", "Minesweeper", "2048", "Asteroids"};
|
||||||
const char* const kBestKeys[3] = {"best_pong", "best_snake", "best_tetris"};
|
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.
|
// The seven pieces in the theme's seven accents: they follow the theme chosen.
|
||||||
uint16_t pieceColor(uint8_t kind) {
|
uint16_t pieceColor(uint8_t kind) {
|
||||||
@@ -112,7 +120,7 @@ void GamesApp::onEnter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GamesApp::pause() {
|
void GamesApp::pause() {
|
||||||
if (which_ != Menu && state_[which_] == State::Playing) state_[which_] = State::Paused;
|
if (which_ != Menu && which_ != Twenty48 && state_[which_] == State::Playing) state_[which_] = State::Paused;
|
||||||
quiet();
|
quiet();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +128,23 @@ void GamesApp::onExit() { pause(); }
|
|||||||
|
|
||||||
void GamesApp::begin() {
|
void GamesApp::begin() {
|
||||||
uint32_t seed = esp_random();
|
uint32_t seed = esp_random();
|
||||||
if (which_ == Pong) pong_.reset(new games::Pong(seed));
|
if (which_ == Pong) {
|
||||||
|
pong_.reset(new games::Pong(seed));
|
||||||
|
levelTilt();
|
||||||
|
}
|
||||||
else if (which_ == Snake) snake_.reset(new games::Snake(seed));
|
else if (which_ == Snake) snake_.reset(new games::Snake(seed));
|
||||||
else if (which_ == Tetris) tetris_.reset(new games::Tetris(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;
|
state_[which_] = State::Playing;
|
||||||
tune_.restart();
|
tune_.restart();
|
||||||
newBest_ = false;
|
newBest_ = false;
|
||||||
@@ -131,10 +153,12 @@ void GamesApp::begin() {
|
|||||||
lastMs_ = millis();
|
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) {
|
void GamesApp::ended(int score) {
|
||||||
state_[which_] = State::Over;
|
state_[which_] = State::Over;
|
||||||
quiet();
|
quiet();
|
||||||
newBest_ = score > best(which_);
|
newBest_ = which_ == Mines ? score > 0 && (best(Mines) == 0 || score < best(Mines)) : score > best(which_);
|
||||||
if (newBest_) {
|
if (newBest_) {
|
||||||
best_[which_] = score;
|
best_[which_] = score;
|
||||||
store_.putInt(kBestKeys[which_], score);
|
store_.putInt(kBestKeys[which_], score);
|
||||||
@@ -146,8 +170,8 @@ bool GamesApp::onKey(const KeyEvent& e) {
|
|||||||
requestRedraw();
|
requestRedraw();
|
||||||
if (which_ == Menu) {
|
if (which_ == Menu) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case Key::Left: chosen_ = (chosen_ + 2) % 3; return true;
|
case Key::Left: chosen_ = (chosen_ + kGames - 1) % kGames; return true;
|
||||||
case Key::Right: chosen_ = (chosen_ + 1) % 3; return true;
|
case Key::Right: chosen_ = (chosen_ + 1) % kGames; return true;
|
||||||
case Key::Select:
|
case Key::Select:
|
||||||
which_ = static_cast<Which>(chosen_);
|
which_ = static_cast<Which>(chosen_);
|
||||||
lastMs_ = millis();
|
lastMs_ = millis();
|
||||||
@@ -166,11 +190,26 @@ bool GamesApp::onKey(const KeyEvent& e) {
|
|||||||
setVolume(volume() + (e.ch == '9' ? 1 : -1));
|
setVolume(volume() + (e.ch == '9' ? 1 : -1));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.key == Key::Back) { // to the list; the Game waits, paused
|
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();
|
pause();
|
||||||
which_ = Menu;
|
which_ = Menu;
|
||||||
return true;
|
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 (state == State::Ready || state == State::Over) {
|
||||||
if (e.key == Key::Select) begin();
|
if (e.key == Key::Select) begin();
|
||||||
return true;
|
return true;
|
||||||
@@ -184,7 +223,87 @@ bool GamesApp::onKey(const KeyEvent& e) {
|
|||||||
pause();
|
pause();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return which_ == Pong ? pongKey(e) : which_ == Snake ? snakeKey(e) : tetrisKey(e);
|
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) {
|
bool GamesApp::pongKey(const KeyEvent& e) {
|
||||||
@@ -230,8 +349,13 @@ void GamesApp::update(uint32_t nowMs) {
|
|||||||
lastMs_ = nowMs;
|
lastMs_ = nowMs;
|
||||||
|
|
||||||
if (which_ == Pong) {
|
if (which_ == Pong) {
|
||||||
int move = keys_.held(Key::Up) ? -1 : keys_.held(Key::Down) ? 1 : 0;
|
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 (!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);
|
uint8_t events = pong_->step(ms / 1000.0f, move);
|
||||||
if (events & Pong::Over) ended(pong_->longestRally());
|
if (events & Pong::Over) ended(pong_->longestRally());
|
||||||
else if (events & Pong::Point) beep(330, 120);
|
else if (events & Pong::Point) beep(330, 120);
|
||||||
@@ -241,6 +365,49 @@ void GamesApp::update(uint32_t nowMs) {
|
|||||||
uint8_t events = snake_->step(ms);
|
uint8_t events = snake_->step(ms);
|
||||||
if (events & Snake::Died) ended(snake_->score());
|
if (events & Snake::Died) ended(snake_->score());
|
||||||
else if (events & Snake::Ate) beep(990, 25);
|
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 {
|
} else {
|
||||||
TetrisInput in = tapped_;
|
TetrisInput in = tapped_;
|
||||||
tapped_ = TetrisInput();
|
tapped_ = TetrisInput();
|
||||||
@@ -271,12 +438,16 @@ void GamesApp::help(std::vector<KeyHelp>& out) const {
|
|||||||
case Pong: return keys::add(out, keys::kGamesPong);
|
case Pong: return keys::add(out, keys::kGamesPong);
|
||||||
case Snake: return keys::add(out, keys::kGamesSnake);
|
case Snake: return keys::add(out, keys::kGamesSnake);
|
||||||
case Tetris: return keys::add(out, keys::kGamesTetris);
|
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);
|
default: return keys::add(out, keys::kGames);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* GamesApp::helpTitle() const {
|
const char* GamesApp::helpTitle() const {
|
||||||
return which_ == Pong ? "Games: Pong" : which_ == Snake ? "Games: Snake" : which_ == Tetris ? "Games: Tetris" : nullptr;
|
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.
|
// A box in the middle of the field: where the Game stands when it is not running.
|
||||||
@@ -297,23 +468,23 @@ void GamesApp::drawBanner(Canvas& c, const std::string& title, const std::string
|
|||||||
c.setTextDatum(top_left);
|
c.setTextDatum(top_left);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The three Games as the Launcher shows Apps: an icon each, the chosen one on a cyan tile, its
|
// 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.
|
// name under the row. A Game waiting paused has the Launcher's orange dot.
|
||||||
void GamesApp::drawMenu(Canvas& c) {
|
void GamesApp::drawMenu(Canvas& c) {
|
||||||
using namespace theme;
|
using namespace theme;
|
||||||
static const uint8_t* const kIcons[3] = {icons::pong, icons::snake, icons::tetris};
|
static const uint8_t* const kIcons[kGames] = {icons::pong, icons::snake, icons::tetris, icons::mines, icons::twenty48, icons::asteroids};
|
||||||
const int tileW = 60, tileH = 36, x0 = (kContent.w - 3 * tileW) / 2, y = kContent.y + 24;
|
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 < 3; i++) {
|
for (int i = 0; i < kGames; i++) {
|
||||||
int x = x0 + i * tileW, ix = x + (tileW - icons::kSize) / 2, iy = y + (tileH - icons::kSize) / 2;
|
int x = x0 + i * tileW, ix = x + (tileW - icons::kSize) / 2, iy = y + (tileH - icons::kSize) / 2;
|
||||||
bool chosen = i == chosen_;
|
bool chosen = i == chosen_;
|
||||||
if (chosen) {
|
if (chosen) {
|
||||||
int tx = ix - 6, tw = icons::kSize + 12;
|
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 + 4, y, tw - 8, tileH, kAccent);
|
||||||
c.fillRect(tx + 2, y + 2, tw - 4, tileH - 4, kAccent);
|
c.fillRect(tx + 2, y + 2, tw - 4, tileH - 4, kAccent);
|
||||||
c.fillRect(tx, y + 4, tw, tileH - 8, kAccent);
|
c.fillRect(tx, y + 4, tw, tileH - 8, kAccent);
|
||||||
}
|
}
|
||||||
c.drawBitmap(ix, iy, kIcons[i], icons::kSize, icons::kSize, chosen ? kFaint : kAccent);
|
c.drawBitmap(ix, iy, kIcons[i], icons::kSize, icons::kSize, chosen ? kFaint : kAccent);
|
||||||
if (state_[i] == State::Paused) {
|
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 - 6, iy - 1, 10, 10, chosen ? kAccent : kBackground);
|
||||||
c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning);
|
c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning);
|
||||||
}
|
}
|
||||||
@@ -324,8 +495,9 @@ void GamesApp::drawMenu(Canvas& c) {
|
|||||||
c.setTextColor(kText);
|
c.setTextColor(kText);
|
||||||
c.drawString(kNames[chosen_], kContent.w / 2, y + tileH + 8);
|
c.drawString(kNames[chosen_], kContent.w / 2, y + tileH + 8);
|
||||||
int b = best(w);
|
int b = best(w);
|
||||||
std::string under = state_[chosen_] == State::Paused ? "paused" : "";
|
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 " : "best ") + std::to_string(b);
|
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.setFont(&fonts::body);
|
||||||
c.setTextColor(kMuted);
|
c.setTextColor(kMuted);
|
||||||
c.drawString(under.c_str(), kContent.w / 2, y + tileH + 8 + kLineHeight + 2);
|
c.drawString(under.c_str(), kContent.w / 2, y + tileH + 8 + kLineHeight + 2);
|
||||||
@@ -353,6 +525,9 @@ void GamesApp::drawPong(Canvas& c) {
|
|||||||
const int b = static_cast<int>(Pong::kBall);
|
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.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);
|
int b = best(Pong);
|
||||||
std::string bestLine = b > 0 ? "longest rally " + std::to_string(b) : "first to 7";
|
std::string bestLine = b > 0 ? "longest rally " + std::to_string(b) : "first to 7";
|
||||||
if (state == State::Ready) drawBanner(c, "Pong", bestLine);
|
if (state == State::Ready) drawBanner(c, "Pong", bestLine);
|
||||||
@@ -437,6 +612,175 @@ void GamesApp::drawTetris(Canvas& c) {
|
|||||||
drawBanner(c, "Game over", newBest_ ? "a new best: " + std::to_string(tetris_->score()) : std::to_string(tetris_->lines()) + " lines");
|
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) {
|
void GamesApp::draw(Canvas& c) {
|
||||||
c.setTextDatum(top_left);
|
c.setTextDatum(top_left);
|
||||||
lastDrawMs_ = millis();
|
lastDrawMs_ = millis();
|
||||||
@@ -444,6 +788,9 @@ void GamesApp::draw(Canvas& c) {
|
|||||||
case Pong: drawPong(c); break;
|
case Pong: drawPong(c); break;
|
||||||
case Snake: drawSnake(c); break;
|
case Snake: drawSnake(c); break;
|
||||||
case Tetris: drawTetris(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;
|
default: drawMenu(c); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,15 @@
|
|||||||
|
|
||||||
#include "app.h"
|
#include "app.h"
|
||||||
#include "key_mapper.h"
|
#include "key_mapper.h"
|
||||||
|
#include "asteroids.h"
|
||||||
#include "key_value_store.h"
|
#include "key_value_store.h"
|
||||||
|
#include "mines.h"
|
||||||
#include "pong.h"
|
#include "pong.h"
|
||||||
#include "settings.h"
|
#include "settings.h"
|
||||||
#include "snake.h"
|
#include "snake.h"
|
||||||
#include "tetris.h"
|
#include "tetris.h"
|
||||||
#include "tune.h"
|
#include "tune.h"
|
||||||
|
#include "twenty48.h"
|
||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
@@ -29,10 +32,12 @@ class GamesApp : public App {
|
|||||||
void help(std::vector<KeyHelp>& out) const override;
|
void help(std::vector<KeyHelp>& out) const override;
|
||||||
const char* helpTitle() const override;
|
const char* helpTitle() const override;
|
||||||
// A Game being played is the device in use, key or no key: the screen stays lit.
|
// A Game being played is the device in use, key or no key: the screen stays lit.
|
||||||
bool keepsScreenOn() const override { return which_ != Menu && state_[which_] == State::Playing; }
|
// Not for the two that wait for a move: there a key is pressed, or nobody is playing.
|
||||||
|
bool keepsScreenOn() const override { return which_ != Menu && which_ != Mines && which_ != Twenty48 && state_[which_] == State::Playing; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum Which { Pong, Snake, Tetris, Menu }; // the first three index the arrays
|
enum Which { Pong, Snake, Tetris, Mines, Twenty48, Asteroids, Menu }; // all but the last index the arrays
|
||||||
|
static constexpr int kGames = 6;
|
||||||
enum class State : uint8_t { Ready, Playing, Paused, Over };
|
enum class State : uint8_t { Ready, Playing, Paused, Over };
|
||||||
|
|
||||||
void begin(); // a new game of the one chosen
|
void begin(); // a new game of the one chosen
|
||||||
@@ -48,6 +53,17 @@ class GamesApp : public App {
|
|||||||
bool pongKey(const KeyEvent& e);
|
bool pongKey(const KeyEvent& e);
|
||||||
bool snakeKey(const KeyEvent& e);
|
bool snakeKey(const KeyEvent& e);
|
||||||
bool tetrisKey(const KeyEvent& e);
|
bool tetrisKey(const KeyEvent& e);
|
||||||
|
bool minesKey(const KeyEvent& e);
|
||||||
|
bool twenty48Key(const KeyEvent& e);
|
||||||
|
bool asteroidsKey(const KeyEvent& e);
|
||||||
|
void readTilt(); // the device's lean, both ways, from the accelerometer, smoothed
|
||||||
|
void levelTilt(); // however it is held now is "straight on"
|
||||||
|
bool pongTilt(); // Pong steered by tilting (0 switches, remembered)
|
||||||
|
bool pongInverted(); // and the other way up (9 switches, remembered)
|
||||||
|
void drawAsteroids(Canvas& c);
|
||||||
|
void moveCursor(int dc, int dr);
|
||||||
|
void drawMines(Canvas& c);
|
||||||
|
void drawTwenty48(Canvas& c);
|
||||||
void drawMenu(Canvas& c);
|
void drawMenu(Canvas& c);
|
||||||
void drawPong(Canvas& c);
|
void drawPong(Canvas& c);
|
||||||
void drawSnake(Canvas& c);
|
void drawSnake(Canvas& c);
|
||||||
@@ -57,14 +73,26 @@ class GamesApp : public App {
|
|||||||
const KeyMapper& keys_;
|
const KeyMapper& keys_;
|
||||||
const Settings& settings_;
|
const Settings& settings_;
|
||||||
KeyValueStore& store_;
|
KeyValueStore& store_;
|
||||||
int chosen_ = 0; // in the row of three
|
int chosen_ = 0; // in the row of games
|
||||||
Which which_ = Menu;
|
Which which_ = Menu;
|
||||||
State state_[3] = {State::Ready, State::Ready, State::Ready};
|
State state_[kGames] = {State::Ready, State::Ready, State::Ready, State::Ready, State::Ready, State::Ready};
|
||||||
int best_[3] = {-1, -1, -1}; // -1: not read from the store yet
|
int best_[kGames] = {-1, -1, -1, -1, -1, -1}; // -1: not read from the store yet
|
||||||
bool newBest_ = false;
|
bool newBest_ = false;
|
||||||
std::unique_ptr<games::Pong> pong_;
|
std::unique_ptr<games::Pong> pong_;
|
||||||
std::unique_ptr<games::Snake> snake_;
|
std::unique_ptr<games::Snake> snake_;
|
||||||
std::unique_ptr<games::Tetris> tetris_;
|
std::unique_ptr<games::Tetris> tetris_;
|
||||||
|
std::unique_ptr<games::Mines> mines_;
|
||||||
|
std::unique_ptr<games::Twenty48> twenty48_;
|
||||||
|
std::unique_ptr<games::Asteroids> asteroids_;
|
||||||
|
// Asteroids is steered by leaning the device left and right, Pong if asked by tilting it up and
|
||||||
|
// down: "level" is how it was held when the game began.
|
||||||
|
float lean_ = 0, level_ = 0, pitch_ = 0, pitchLevel_ = 0;
|
||||||
|
int pongTilt_ = -1, pongInverted_ = -1; // -1: not read from the store yet
|
||||||
|
bool fired_ = false;
|
||||||
|
// Minesweeper's cursor, and the arrow held to run it along.
|
||||||
|
int cursorCol_ = 0, cursorRow_ = 0, runDir_ = -1;
|
||||||
|
uint32_t runMs_ = 0, shownSeconds_ = 0;
|
||||||
|
bool running_ = false;
|
||||||
uint32_t lastMs_ = 0, lastDrawMs_ = 0;
|
uint32_t lastMs_ = 0, lastDrawMs_ = 0;
|
||||||
// Keys that came as presses since the last step: a tap moves too, held or not.
|
// Keys that came as presses since the last step: a tap moves too, held or not.
|
||||||
int tapMove_ = 0;
|
int tapMove_ = 0;
|
||||||
|
|||||||
@@ -3,6 +3,41 @@
|
|||||||
|
|
||||||
namespace roro::icons {
|
namespace roro::icons {
|
||||||
|
|
||||||
|
const uint8_t asteroids[128] = {
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x3f, 0xc0, // ..................########......
|
||||||
|
0x00, 0x00, 0x3f, 0xc0, // ..................########......
|
||||||
|
0x00, 0x00, 0xc0, 0x30, // ................##........##....
|
||||||
|
0x00, 0x00, 0xc0, 0x30, // ................##........##....
|
||||||
|
0x00, 0x03, 0x00, 0x0c, // ..............##............##..
|
||||||
|
0x00, 0x03, 0x00, 0x0c, // ..............##............##..
|
||||||
|
0x00, 0x03, 0x00, 0x30, // ..............##..........##....
|
||||||
|
0x00, 0x03, 0x00, 0x30, // ..............##..........##....
|
||||||
|
0x00, 0x00, 0xc0, 0x0c, // ................##..........##..
|
||||||
|
0x00, 0x00, 0xc0, 0x0c, // ................##..........##..
|
||||||
|
0x00, 0x00, 0x3f, 0xf0, // ..................##########....
|
||||||
|
0x00, 0x00, 0x3f, 0xf0, // ..................##########....
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x03, 0x00, 0x00, 0x00, // ......##........................
|
||||||
|
0x03, 0x00, 0x00, 0x00, // ......##........................
|
||||||
|
0x03, 0x00, 0x03, 0x00, // ......##..............##........
|
||||||
|
0x03, 0x00, 0x03, 0x00, // ......##..............##........
|
||||||
|
0x0c, 0xc0, 0x00, 0x00, // ....##..##......................
|
||||||
|
0x0c, 0xc0, 0x00, 0x00, // ....##..##......................
|
||||||
|
0x0c, 0xc0, 0x0c, 0x00, // ....##..##..........##..........
|
||||||
|
0x0c, 0xc0, 0x0c, 0x00, // ....##..##..........##..........
|
||||||
|
0x30, 0x30, 0x00, 0x00, // ..##......##....................
|
||||||
|
0x30, 0x30, 0x00, 0x00, // ..##......##....................
|
||||||
|
0x33, 0x30, 0x00, 0x00, // ..##..##..##....................
|
||||||
|
0x33, 0x30, 0x00, 0x00, // ..##..##..##....................
|
||||||
|
0x3c, 0xf0, 0x00, 0x00, // ..####..####....................
|
||||||
|
0x3c, 0xf0, 0x00, 0x00, // ..####..####....................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
};
|
||||||
|
|
||||||
const uint8_t games[128] = {
|
const uint8_t games[128] = {
|
||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
@@ -178,6 +213,41 @@ const uint8_t lora[128] = {
|
|||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uint8_t mines[128] = {
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x03, 0xc0, 0x00, // ..............####..............
|
||||||
|
0x00, 0x03, 0xc0, 0x00, // ..............####..............
|
||||||
|
0x0c, 0x03, 0xc0, 0x30, // ....##........####........##....
|
||||||
|
0x0c, 0x03, 0xc0, 0x30, // ....##........####........##....
|
||||||
|
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
|
||||||
|
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
|
||||||
|
0x00, 0xff, 0xff, 0x00, // ........################........
|
||||||
|
0x00, 0xff, 0xff, 0x00, // ........################........
|
||||||
|
0x03, 0xf0, 0xff, 0xc0, // ......######....##########......
|
||||||
|
0x03, 0xf0, 0xff, 0xc0, // ......######....##########......
|
||||||
|
0x03, 0xc0, 0xff, 0xc0, // ......####......##########......
|
||||||
|
0x03, 0xc0, 0xff, 0xc0, // ......####......##########......
|
||||||
|
0x3f, 0xff, 0xff, 0xfc, // ..############################..
|
||||||
|
0x3f, 0xff, 0xff, 0xfc, // ..############################..
|
||||||
|
0x3f, 0xff, 0xff, 0xfc, // ..############################..
|
||||||
|
0x3f, 0xff, 0xff, 0xfc, // ..############################..
|
||||||
|
0x03, 0xff, 0xff, 0xc0, // ......####################......
|
||||||
|
0x03, 0xff, 0xff, 0xc0, // ......####################......
|
||||||
|
0x03, 0xff, 0xff, 0xc0, // ......####################......
|
||||||
|
0x03, 0xff, 0xff, 0xc0, // ......####################......
|
||||||
|
0x00, 0xff, 0xff, 0x00, // ........################........
|
||||||
|
0x00, 0xff, 0xff, 0x00, // ........################........
|
||||||
|
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
|
||||||
|
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
|
||||||
|
0x0c, 0x03, 0xc0, 0x30, // ....##........####........##....
|
||||||
|
0x0c, 0x03, 0xc0, 0x30, // ....##........####........##....
|
||||||
|
0x00, 0x03, 0xc0, 0x00, // ..............####..............
|
||||||
|
0x00, 0x03, 0xc0, 0x00, // ..............####..............
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
};
|
||||||
|
|
||||||
const uint8_t notes[128] = {
|
const uint8_t notes[128] = {
|
||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
@@ -493,6 +563,41 @@ const uint8_t tetris[128] = {
|
|||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uint8_t twenty48[128] = {
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x30, 0x0c, 0xff, 0xf0, // ..##........##..############....
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##....
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x3f, 0xfc, 0xff, 0xf0, // ..############..############....
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
};
|
||||||
|
|
||||||
const uint8_t wifi_tools[128] = {
|
const uint8_t wifi_tools[128] = {
|
||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
0x00, 0x00, 0x00, 0x00, // ................................
|
0x00, 0x00, 0x00, 0x00, // ................................
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ namespace roro::icons {
|
|||||||
// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.
|
// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.
|
||||||
constexpr int kSize = 32;
|
constexpr int kSize = 32;
|
||||||
|
|
||||||
|
extern const uint8_t asteroids[128]; // asteroids.png
|
||||||
extern const uint8_t games[128]; // games.png
|
extern const uint8_t games[128]; // games.png
|
||||||
extern const uint8_t gemini[128]; // gemini.png
|
extern const uint8_t gemini[128]; // gemini.png
|
||||||
extern const uint8_t gnss[128]; // gnss.png
|
extern const uint8_t gnss[128]; // gnss.png
|
||||||
extern const uint8_t irc[128]; // irc.png
|
extern const uint8_t irc[128]; // irc.png
|
||||||
extern const uint8_t lora[128]; // lora.png
|
extern const uint8_t lora[128]; // lora.png
|
||||||
|
extern const uint8_t mines[128]; // mines.png
|
||||||
extern const uint8_t notes[128]; // notes.png
|
extern const uint8_t notes[128]; // notes.png
|
||||||
extern const uint8_t pong[128]; // pong.png
|
extern const uint8_t pong[128]; // pong.png
|
||||||
extern const uint8_t settings[128]; // settings.png
|
extern const uint8_t settings[128]; // settings.png
|
||||||
@@ -22,6 +24,7 @@ extern const uint8_t ssh[128]; // ssh.png
|
|||||||
extern const uint8_t storage[128]; // storage.png
|
extern const uint8_t storage[128]; // storage.png
|
||||||
extern const uint8_t system[128]; // system.png
|
extern const uint8_t system[128]; // system.png
|
||||||
extern const uint8_t tetris[128]; // tetris.png
|
extern const uint8_t tetris[128]; // tetris.png
|
||||||
|
extern const uint8_t twenty48[128]; // twenty48.png
|
||||||
extern const uint8_t wifi_tools[128]; // wifi-tools.png
|
extern const uint8_t wifi_tools[128]; // wifi-tools.png
|
||||||
|
|
||||||
} // namespace roro::icons
|
} // namespace roro::icons
|
||||||
|
|||||||
@@ -121,6 +121,25 @@ void test_pong_the_device_can_be_beaten() {
|
|||||||
TEST_ASSERT_TRUE(playerPoints > 0);
|
TEST_ASSERT_TRUE(playerPoints > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_pong_a_tilt_puts_the_paddle_where_it_points() {
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kHeight / 2, tiltTarget(0.3f, 0.3f)); // held as at the start: the middle
|
||||||
|
TEST_ASSERT_TRUE(tiltTarget(0.2f, 0.3f) < Pong::kHeight / 2); // top lowered: up
|
||||||
|
TEST_ASSERT_TRUE(tiltTarget(0.4f, 0.3f) > Pong::kHeight / 2);
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kPaddleH / 2, tiltTarget(-0.9f, 0.3f)); // and no further than the edge
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kHeight - Pong::kPaddleH / 2, tiltTarget(0.9f, 0.3f));
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(1, moveToward(20, 80));
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(-1, moveToward(80, 20));
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.5f, moveToward(60, 65));
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(0, moveToward(60, 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_pong_a_tilted_paddle_settles_on_its_target() {
|
||||||
|
Pong p(3);
|
||||||
|
float target = tiltTarget(0.4f, 0.3f);
|
||||||
|
for (int i = 0; i < 200; i++) p.step(0.01f, moveToward(p.playerY(), target));
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(0.5, target, p.playerY());
|
||||||
|
}
|
||||||
|
|
||||||
void test_pong_the_device_paddle_has_a_top_speed() {
|
void test_pong_the_device_paddle_has_a_top_speed() {
|
||||||
Pong p(13);
|
Pong p(13);
|
||||||
float before = p.deviceY();
|
float before = p.deviceY();
|
||||||
@@ -658,6 +677,8 @@ int main() {
|
|||||||
RUN_TEST(test_pong_a_miss_is_a_point_for_the_other_side);
|
RUN_TEST(test_pong_a_miss_is_a_point_for_the_other_side);
|
||||||
RUN_TEST(test_pong_ends_at_seven);
|
RUN_TEST(test_pong_ends_at_seven);
|
||||||
RUN_TEST(test_pong_the_device_can_be_beaten);
|
RUN_TEST(test_pong_the_device_can_be_beaten);
|
||||||
|
RUN_TEST(test_pong_a_tilt_puts_the_paddle_where_it_points);
|
||||||
|
RUN_TEST(test_pong_a_tilted_paddle_settles_on_its_target);
|
||||||
RUN_TEST(test_pong_the_device_paddle_has_a_top_speed);
|
RUN_TEST(test_pong_the_device_paddle_has_a_top_speed);
|
||||||
RUN_TEST(test_snake_starts_in_the_middle_three_long);
|
RUN_TEST(test_snake_starts_in_the_middle_three_long);
|
||||||
RUN_TEST(test_snake_moves_at_its_pace);
|
RUN_TEST(test_snake_moves_at_its_pace);
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "asteroids.h"
|
||||||
|
#include "mines.h"
|
||||||
|
#include "twenty48.h"
|
||||||
|
|
||||||
|
using namespace roro::games;
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
// ---- Minesweeper ----
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
uint16_t at(int col, int row) { return static_cast<uint16_t>(row * Mines::kCols + col); }
|
||||||
|
int countOpen(const Mines& m) {
|
||||||
|
int n = 0;
|
||||||
|
for (int r = 0; r < Mines::kRows; r++)
|
||||||
|
for (int c = 0; c < Mines::kCols; c++) n += m.open(c, r);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
int countMines(const Mines& m) {
|
||||||
|
int n = 0;
|
||||||
|
for (int r = 0; r < Mines::kRows; r++)
|
||||||
|
for (int c = 0; c < Mines::kCols; c++) n += m.mine(c, r);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void test_mines_the_first_dig_is_never_a_mine_and_opens_something() {
|
||||||
|
for (uint32_t seed = 1; seed <= 30; seed++) {
|
||||||
|
Mines m(seed);
|
||||||
|
TEST_ASSERT_TRUE(m.phase() == Mines::Phase::Fresh);
|
||||||
|
int col = static_cast<int>(seed * 7 % Mines::kCols), row = static_cast<int>(seed * 3 % Mines::kRows);
|
||||||
|
uint8_t e = m.dig(col, row);
|
||||||
|
TEST_ASSERT_TRUE(e & Mines::Opened);
|
||||||
|
TEST_ASSERT_FALSE(e & Mines::Boom);
|
||||||
|
TEST_ASSERT_EQUAL(Mines::kMines, countMines(m));
|
||||||
|
TEST_ASSERT_EQUAL(0, m.around(col, row)); // nor is one next to it
|
||||||
|
TEST_ASSERT_TRUE(countOpen(m) >= 4); // so its neighbours opened too
|
||||||
|
TEST_ASSERT_TRUE(m.phase() == Mines::Phase::Running);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_numbers_count_the_mines_around() {
|
||||||
|
Mines m(1);
|
||||||
|
const uint16_t where[] = {at(5, 5), at(6, 5), at(7, 6)};
|
||||||
|
m.lay(where, 3);
|
||||||
|
TEST_ASSERT_EQUAL(3, m.around(6, 6)); // all three touch it
|
||||||
|
TEST_ASSERT_EQUAL(1, m.around(4, 4)); // only (5,5), by a corner
|
||||||
|
TEST_ASSERT_EQUAL(2, m.around(6, 4)); // the two above which it sits
|
||||||
|
TEST_ASSERT_EQUAL(0, m.around(20, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_an_empty_cell_opens_up_to_the_numbers() {
|
||||||
|
Mines m(1);
|
||||||
|
const uint16_t where[] = {at(0, 0)};
|
||||||
|
m.lay(where, 1);
|
||||||
|
m.dig(15, 6);
|
||||||
|
TEST_ASSERT_EQUAL(Mines::kCols * Mines::kRows - 1, countOpen(m)); // everything but the mine
|
||||||
|
TEST_ASSERT_TRUE(m.open(1, 1));
|
||||||
|
TEST_ASSERT_FALSE(m.open(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_a_number_opens_alone() {
|
||||||
|
Mines m(1);
|
||||||
|
const uint16_t where[] = {at(5, 5)};
|
||||||
|
m.lay(where, 1);
|
||||||
|
TEST_ASSERT_EQUAL(Mines::Opened, m.dig(4, 4));
|
||||||
|
TEST_ASSERT_EQUAL(1, countOpen(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_digging_a_mine_loses() {
|
||||||
|
Mines m(1);
|
||||||
|
const uint16_t where[] = {at(5, 5), at(20, 3)};
|
||||||
|
m.lay(where, 2);
|
||||||
|
m.dig(4, 4);
|
||||||
|
uint8_t e = m.dig(5, 5);
|
||||||
|
TEST_ASSERT_TRUE(e & Mines::Boom);
|
||||||
|
TEST_ASSERT_TRUE(m.phase() == Mines::Phase::Lost);
|
||||||
|
TEST_ASSERT_EQUAL(0, m.dig(0, 0)); // nothing more
|
||||||
|
TEST_ASSERT_EQUAL(0, m.flag(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_a_flag_guards_its_cell_and_counts() {
|
||||||
|
Mines m(1);
|
||||||
|
const uint16_t where[] = {at(5, 5)};
|
||||||
|
m.lay(where, 1);
|
||||||
|
TEST_ASSERT_EQUAL(Mines::kMines, m.minesLeft());
|
||||||
|
TEST_ASSERT_EQUAL(Mines::Flagged, m.flag(5, 5));
|
||||||
|
TEST_ASSERT_EQUAL(Mines::kMines - 1, m.minesLeft());
|
||||||
|
TEST_ASSERT_EQUAL(0, m.dig(5, 5)); // not while it is flagged
|
||||||
|
TEST_ASSERT_FALSE(m.open(5, 5));
|
||||||
|
m.flag(5, 5); // taken off
|
||||||
|
TEST_ASSERT_EQUAL(Mines::kMines, m.minesLeft());
|
||||||
|
m.dig(4, 4);
|
||||||
|
TEST_ASSERT_EQUAL(0, m.flag(4, 4)); // an open cell takes no flag
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_a_number_with_its_flags_opens_the_rest() {
|
||||||
|
Mines m(1);
|
||||||
|
const uint16_t where[] = {at(5, 5), at(20, 3)};
|
||||||
|
m.lay(where, 2);
|
||||||
|
m.dig(4, 4); // a 1
|
||||||
|
TEST_ASSERT_EQUAL(0, m.dig(4, 4)); // no flag yet: nothing
|
||||||
|
m.flag(5, 5);
|
||||||
|
uint8_t e = m.dig(4, 4);
|
||||||
|
TEST_ASSERT_TRUE(e & Mines::Opened);
|
||||||
|
TEST_ASSERT_TRUE(m.open(3, 3));
|
||||||
|
TEST_ASSERT_TRUE(m.open(4, 5));
|
||||||
|
TEST_ASSERT_FALSE(m.open(5, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_a_wrong_flag_and_the_same_dig_loses() {
|
||||||
|
Mines m(1);
|
||||||
|
const uint16_t where[] = {at(5, 5), at(20, 3)};
|
||||||
|
m.lay(where, 2);
|
||||||
|
m.dig(4, 4);
|
||||||
|
m.flag(3, 3); // the wrong cell
|
||||||
|
TEST_ASSERT_TRUE(m.dig(4, 4) & Mines::Boom);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_opening_everything_else_wins() {
|
||||||
|
Mines m(1);
|
||||||
|
uint16_t many[Mines::kMines];
|
||||||
|
for (int i = 0; i < Mines::kMines; i++) many[i] = at(i % Mines::kCols, i / Mines::kCols); // the first row and part of the second
|
||||||
|
m.lay(many, Mines::kMines);
|
||||||
|
uint8_t all = 0;
|
||||||
|
for (int r = 0; r < Mines::kRows; r++)
|
||||||
|
for (int c = 0; c < Mines::kCols; c++)
|
||||||
|
if (!m.mine(c, r)) all |= m.dig(c, r);
|
||||||
|
TEST_ASSERT_TRUE(all & Mines::Won);
|
||||||
|
TEST_ASSERT_FALSE(all & Mines::Boom);
|
||||||
|
TEST_ASSERT_TRUE(m.phase() == Mines::Phase::Won);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_mines_the_clock_runs_from_the_first_dig_to_the_end() {
|
||||||
|
Mines m(1);
|
||||||
|
m.tick(5000);
|
||||||
|
TEST_ASSERT_EQUAL(0, m.seconds()); // not before the first dig
|
||||||
|
const uint16_t where[] = {at(5, 5), at(20, 3)};
|
||||||
|
m.lay(where, 2);
|
||||||
|
m.dig(4, 4);
|
||||||
|
m.tick(2500);
|
||||||
|
TEST_ASSERT_EQUAL(2, m.seconds());
|
||||||
|
m.dig(5, 5);
|
||||||
|
m.tick(9000);
|
||||||
|
TEST_ASSERT_EQUAL(2, m.seconds()); // stopped
|
||||||
|
m.start();
|
||||||
|
TEST_ASSERT_EQUAL(0, m.seconds());
|
||||||
|
TEST_ASSERT_EQUAL(0, countOpen(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2048 ----
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void board(Twenty48& t, std::initializer_list<uint8_t> powers) {
|
||||||
|
uint8_t p[16] = {};
|
||||||
|
int i = 0;
|
||||||
|
for (uint8_t v : powers) p[i++] = v;
|
||||||
|
t.set(p);
|
||||||
|
}
|
||||||
|
int tiles(const Twenty48& t) {
|
||||||
|
int n = 0;
|
||||||
|
for (int r = 0; r < 4; r++)
|
||||||
|
for (int c = 0; c < 4; c++) n += t.power(c, r) != 0;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void test_2048_starts_with_two_tiles() {
|
||||||
|
for (uint32_t seed = 1; seed <= 20; seed++) {
|
||||||
|
Twenty48 t(seed);
|
||||||
|
TEST_ASSERT_EQUAL(2, tiles(t));
|
||||||
|
TEST_ASSERT_EQUAL(0, t.score());
|
||||||
|
TEST_ASSERT_FALSE(t.over());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_tiles_slide_to_the_side() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {0, 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
uint8_t e = t.slide(Twenty48::Dir::Left);
|
||||||
|
TEST_ASSERT_TRUE(e & Twenty48::Moved);
|
||||||
|
TEST_ASSERT_FALSE(e & Twenty48::Merged);
|
||||||
|
TEST_ASSERT_EQUAL(2, t.value(0, 0));
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(1, 0));
|
||||||
|
TEST_ASSERT_EQUAL(3, tiles(t)); // and a new one came
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_two_alike_become_one_and_score() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
uint8_t e = t.slide(Twenty48::Dir::Right);
|
||||||
|
TEST_ASSERT_TRUE(e & Twenty48::Merged);
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(3, 0));
|
||||||
|
TEST_ASSERT_EQUAL(4, t.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_a_tile_merges_once_a_move() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {1, 1, 1, 1, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
t.slide(Twenty48::Dir::Left);
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(0, 0)); // 2 2 2 2 is 4 4, not 8
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(1, 0));
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(0, 1)); // 4 2 2 is 4 4, not 8
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(1, 1));
|
||||||
|
TEST_ASSERT_EQUAL(12, t.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_the_pair_nearest_the_wall_merges_first() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
t.slide(Twenty48::Dir::Right);
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(3, 0));
|
||||||
|
TEST_ASSERT_EQUAL(2, t.value(2, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_up_and_down() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {1, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
t.slide(Twenty48::Dir::Down);
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(0, 3)); // the 4 at the bottom...
|
||||||
|
TEST_ASSERT_EQUAL(4, t.value(0, 2)); // ...and the two 2s made into one above it
|
||||||
|
board(t, {0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0});
|
||||||
|
t.slide(Twenty48::Dir::Up);
|
||||||
|
TEST_ASSERT_EQUAL(16, t.value(3, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_a_move_that_moves_nothing_is_no_move() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
TEST_ASSERT_EQUAL(0, t.slide(Twenty48::Dir::Left));
|
||||||
|
TEST_ASSERT_EQUAL(0, t.slide(Twenty48::Dir::Up));
|
||||||
|
TEST_ASSERT_EQUAL(2, tiles(t)); // no new tile either
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_reaching_2048_is_said_once() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {10, 10, 0, 0, 10, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
uint8_t e = t.slide(Twenty48::Dir::Left);
|
||||||
|
TEST_ASSERT_TRUE(e & Twenty48::Reached);
|
||||||
|
TEST_ASSERT_TRUE(t.reached());
|
||||||
|
TEST_ASSERT_EQUAL(2048, t.value(0, 0));
|
||||||
|
TEST_ASSERT_FALSE(t.over()); // and it goes on
|
||||||
|
board(t, {10, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
|
||||||
|
TEST_ASSERT_FALSE(t.slide(Twenty48::Dir::Left) & Twenty48::Reached);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_is_over_when_nothing_can_move() {
|
||||||
|
Twenty48 t(1);
|
||||||
|
board(t, {1, 2, 1, 2, 2, 1, 2, 1, 1, 2, 1, 2, 2, 1, 2, 1});
|
||||||
|
TEST_ASSERT_TRUE(t.over());
|
||||||
|
TEST_ASSERT_EQUAL(0, t.slide(Twenty48::Dir::Left));
|
||||||
|
// One pair left: it is not over, and the pair can be made into one.
|
||||||
|
board(t, {1, 2, 1, 2, 2, 1, 2, 1, 1, 2, 1, 2, 2, 1, 3, 3});
|
||||||
|
TEST_ASSERT_FALSE(t.over());
|
||||||
|
uint8_t e = t.slide(Twenty48::Dir::Left);
|
||||||
|
TEST_ASSERT_TRUE(e & Twenty48::Merged);
|
||||||
|
TEST_ASSERT_EQUAL(16, t.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_the_same_seed_is_the_same_game() {
|
||||||
|
Twenty48 a(9), b(9);
|
||||||
|
const Twenty48::Dir moves[] = {Twenty48::Dir::Left, Twenty48::Dir::Up, Twenty48::Dir::Right, Twenty48::Dir::Down};
|
||||||
|
for (int i = 0; i < 60; i++) {
|
||||||
|
a.slide(moves[i % 4]);
|
||||||
|
b.slide(moves[i % 4]);
|
||||||
|
}
|
||||||
|
for (int r = 0; r < 4; r++)
|
||||||
|
for (int c = 0; c < 4; c++) TEST_ASSERT_EQUAL(a.power(c, r), b.power(c, r));
|
||||||
|
TEST_ASSERT_EQUAL(a.score(), b.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_2048_new_tiles_are_twos_and_sometimes_fours() {
|
||||||
|
int twos = 0, fours = 0;
|
||||||
|
for (uint32_t seed = 1; seed <= 300; seed++) {
|
||||||
|
Twenty48 t(seed);
|
||||||
|
for (int r = 0; r < 4; r++)
|
||||||
|
for (int c = 0; c < 4; c++) {
|
||||||
|
twos += t.value(c, r) == 2;
|
||||||
|
fours += t.value(c, r) == 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL(600, twos + fours);
|
||||||
|
TEST_ASSERT_TRUE(fours > 20 && fours < 120); // about one in ten
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Asteroids ----
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
Asteroids::Rock rockAt(float x, float y, uint8_t size, float vx = 0, float vy = 0) {
|
||||||
|
Asteroids::Rock r{};
|
||||||
|
r.x = x;
|
||||||
|
r.y = y;
|
||||||
|
r.vx = vx;
|
||||||
|
r.vy = vy;
|
||||||
|
r.size = size;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
void fly(Asteroids& a, float seconds, AsteroidsInput in = AsteroidsInput()) {
|
||||||
|
for (float t = 0; t < seconds; t += 0.02f) a.step(0.02f, in);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void test_asteroids_a_lean_is_a_turn() {
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(0, tiltTurn(0.02f, 0)); // a steady hand: straight on
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(0, tiltTurn(0.32f, 0.30f)); // around how it was held at the start
|
||||||
|
TEST_ASSERT_TRUE(tiltTurn(0.2f, 0) > 0.3f && tiltTurn(0.2f, 0) < 0.7f);
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(1, tiltTurn(0.6f, 0));
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(-1, tiltTurn(-0.6f, 0));
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(-tiltTurn(0.2f, 0), tiltTurn(-0.2f, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_a_wave_starts_away_from_the_ship() {
|
||||||
|
for (uint32_t seed = 1; seed <= 20; seed++) {
|
||||||
|
Asteroids a(seed);
|
||||||
|
TEST_ASSERT_EQUAL(1, a.wave());
|
||||||
|
TEST_ASSERT_EQUAL(3, a.rocks().size());
|
||||||
|
for (auto& r : a.rocks()) {
|
||||||
|
TEST_ASSERT_EQUAL(3, r.size);
|
||||||
|
TEST_ASSERT_TRUE(std::hypot(r.x - a.shipX(), r.y - a.shipY()) > 40);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL(Asteroids::kLives, a.lives());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_the_ship_turns_and_thrusts() {
|
||||||
|
Asteroids a(1);
|
||||||
|
a.setRocks({rockAt(10, 10, 1)});
|
||||||
|
AsteroidsInput in;
|
||||||
|
in.turn = 1;
|
||||||
|
fly(a, 0.5f, in);
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(0.1, Asteroids::kTurnSpeed * 0.5f, a.shipAngle());
|
||||||
|
a.placeShip(120, 60, 0); // pointing up
|
||||||
|
in = AsteroidsInput();
|
||||||
|
in.thrust = true;
|
||||||
|
fly(a, 0.5f, in);
|
||||||
|
TEST_ASSERT_TRUE(a.shipY() < 50);
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(1, 120, a.shipX());
|
||||||
|
TEST_ASSERT_TRUE(a.thrusting());
|
||||||
|
float y = a.shipY();
|
||||||
|
fly(a, 3); // no thrust: it drifts on, slowing
|
||||||
|
TEST_ASSERT_TRUE(a.shipY() != y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_everything_wraps_round() {
|
||||||
|
Asteroids a(1);
|
||||||
|
a.setRocks({rockAt(235, 60, 1, 40, 0)});
|
||||||
|
fly(a, 0.5f);
|
||||||
|
TEST_ASSERT_TRUE(a.rocks()[0].x < 30);
|
||||||
|
a.placeShip(120, 2, 0);
|
||||||
|
AsteroidsInput in;
|
||||||
|
in.thrust = true;
|
||||||
|
fly(a, 0.3f, in);
|
||||||
|
TEST_ASSERT_TRUE(a.shipY() > 100); // out at the top, in at the bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_a_bullet_breaks_a_rock_in_two() {
|
||||||
|
Asteroids a(1);
|
||||||
|
a.placeShip(120, 100, 0);
|
||||||
|
a.setRocks({rockAt(120, 60, 3), rockAt(10, 10, 1)});
|
||||||
|
AsteroidsInput in;
|
||||||
|
in.fire = true;
|
||||||
|
uint8_t all = a.step(0.02f, in);
|
||||||
|
TEST_ASSERT_TRUE(all & Asteroids::Shot);
|
||||||
|
for (int i = 0; i < 20; i++) all |= a.step(0.02f, AsteroidsInput());
|
||||||
|
TEST_ASSERT_TRUE(all & Asteroids::Hit);
|
||||||
|
TEST_ASSERT_EQUAL(20, a.score());
|
||||||
|
int middle = 0;
|
||||||
|
for (auto& r : a.rocks()) middle += r.size == 2;
|
||||||
|
TEST_ASSERT_EQUAL(2, middle);
|
||||||
|
TEST_ASSERT_EQUAL(0, a.bullets().size()); // spent on it
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_a_small_rock_is_gone_and_scores_most() {
|
||||||
|
Asteroids a(1);
|
||||||
|
a.placeShip(120, 100, 0);
|
||||||
|
a.setRocks({rockAt(120, 70, 1), rockAt(10, 10, 1)});
|
||||||
|
AsteroidsInput in;
|
||||||
|
in.fire = true;
|
||||||
|
a.step(0.02f, in);
|
||||||
|
fly(a, 0.4f);
|
||||||
|
TEST_ASSERT_EQUAL(100, a.score());
|
||||||
|
TEST_ASSERT_EQUAL(1, a.rocks().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_bullets_are_few_and_short_lived() {
|
||||||
|
Asteroids a(1);
|
||||||
|
a.setRocks({rockAt(10, 10, 1)});
|
||||||
|
a.placeShip(120, 60, 1.57f); // to the right, where nothing is
|
||||||
|
AsteroidsInput in;
|
||||||
|
in.fire = true;
|
||||||
|
for (int i = 0; i < 10; i++) a.step(0.01f, in);
|
||||||
|
TEST_ASSERT_EQUAL(Asteroids::kMaxBullets, a.bullets().size());
|
||||||
|
fly(a, Asteroids::kBulletLife + 0.1f);
|
||||||
|
TEST_ASSERT_EQUAL(0, a.bullets().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_a_rock_costs_a_ship_and_the_last_ends_it() {
|
||||||
|
Asteroids a(1);
|
||||||
|
a.setRocks({rockAt(120, 60, 3)});
|
||||||
|
a.placeShip(120, 60, 0);
|
||||||
|
fly(a, 0.5f); // shielded for a moment after coming in
|
||||||
|
TEST_ASSERT_EQUAL(Asteroids::kLives, a.lives());
|
||||||
|
uint8_t all = 0;
|
||||||
|
for (int i = 0; i < 200 && !(all & Asteroids::Died); i++) all |= a.step(0.02f, AsteroidsInput());
|
||||||
|
TEST_ASSERT_TRUE(all & Asteroids::Died);
|
||||||
|
TEST_ASSERT_EQUAL(Asteroids::kLives - 1, a.lives());
|
||||||
|
TEST_ASSERT_FALSE(a.shipShown()); // out for a moment, then back in the middle
|
||||||
|
fly(a, Asteroids::kRespawnWait + 0.1f);
|
||||||
|
TEST_ASSERT_TRUE(a.shipShown());
|
||||||
|
TEST_ASSERT_TRUE(a.shielded());
|
||||||
|
for (int life = 0; life < 2; life++) {
|
||||||
|
a.setRocks({rockAt(a.shipX(), a.shipY(), 3)});
|
||||||
|
fly(a, Asteroids::kShielded + Asteroids::kRespawnWait + 0.5f);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(a.over());
|
||||||
|
TEST_ASSERT_EQUAL(0, a.lives());
|
||||||
|
TEST_ASSERT_EQUAL(0, a.step(1, AsteroidsInput()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_asteroids_the_last_rock_brings_the_next_wave() {
|
||||||
|
Asteroids a(1);
|
||||||
|
a.placeShip(120, 100, 0);
|
||||||
|
a.setRocks({rockAt(120, 70, 1)});
|
||||||
|
AsteroidsInput in;
|
||||||
|
in.fire = true;
|
||||||
|
uint8_t all = a.step(0.02f, in);
|
||||||
|
for (int i = 0; i < 30; i++) all |= a.step(0.02f, AsteroidsInput());
|
||||||
|
TEST_ASSERT_TRUE(all & Asteroids::Wave);
|
||||||
|
TEST_ASSERT_EQUAL(2, a.wave());
|
||||||
|
TEST_ASSERT_EQUAL(4, a.rocks().size()); // one more each wave
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_mines_the_first_dig_is_never_a_mine_and_opens_something);
|
||||||
|
RUN_TEST(test_mines_numbers_count_the_mines_around);
|
||||||
|
RUN_TEST(test_mines_an_empty_cell_opens_up_to_the_numbers);
|
||||||
|
RUN_TEST(test_mines_a_number_opens_alone);
|
||||||
|
RUN_TEST(test_mines_digging_a_mine_loses);
|
||||||
|
RUN_TEST(test_mines_a_flag_guards_its_cell_and_counts);
|
||||||
|
RUN_TEST(test_mines_a_number_with_its_flags_opens_the_rest);
|
||||||
|
RUN_TEST(test_mines_a_wrong_flag_and_the_same_dig_loses);
|
||||||
|
RUN_TEST(test_mines_opening_everything_else_wins);
|
||||||
|
RUN_TEST(test_mines_the_clock_runs_from_the_first_dig_to_the_end);
|
||||||
|
RUN_TEST(test_2048_starts_with_two_tiles);
|
||||||
|
RUN_TEST(test_2048_tiles_slide_to_the_side);
|
||||||
|
RUN_TEST(test_2048_two_alike_become_one_and_score);
|
||||||
|
RUN_TEST(test_2048_a_tile_merges_once_a_move);
|
||||||
|
RUN_TEST(test_2048_the_pair_nearest_the_wall_merges_first);
|
||||||
|
RUN_TEST(test_2048_up_and_down);
|
||||||
|
RUN_TEST(test_2048_a_move_that_moves_nothing_is_no_move);
|
||||||
|
RUN_TEST(test_2048_reaching_2048_is_said_once);
|
||||||
|
RUN_TEST(test_2048_is_over_when_nothing_can_move);
|
||||||
|
RUN_TEST(test_2048_the_same_seed_is_the_same_game);
|
||||||
|
RUN_TEST(test_2048_new_tiles_are_twos_and_sometimes_fours);
|
||||||
|
RUN_TEST(test_asteroids_a_lean_is_a_turn);
|
||||||
|
RUN_TEST(test_asteroids_a_wave_starts_away_from_the_ship);
|
||||||
|
RUN_TEST(test_asteroids_the_ship_turns_and_thrusts);
|
||||||
|
RUN_TEST(test_asteroids_everything_wraps_round);
|
||||||
|
RUN_TEST(test_asteroids_a_bullet_breaks_a_rock_in_two);
|
||||||
|
RUN_TEST(test_asteroids_a_small_rock_is_gone_and_scores_most);
|
||||||
|
RUN_TEST(test_asteroids_bullets_are_few_and_short_lived);
|
||||||
|
RUN_TEST(test_asteroids_a_rock_costs_a_ship_and_the_last_ends_it);
|
||||||
|
RUN_TEST(test_asteroids_the_last_rock_brings_the_next_wave);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||