Games: Asteroids as a state machine, and a lean as a turn

A ship, rocks that break in two and again in two, short-lived bullets,
three ships and waves that grow, on a field that wraps round. Its turn
comes from -1 to 1, which tiltTurn makes from the device's lean around
the angle it was held at when the game began. Host-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
2026-10-15 12:06:29 +02:00
co-authored by Claude Opus 5.5
parent b1e6242b29
commit af9077cb94
3 changed files with 421 additions and 0 deletions
+180
View File
@@ -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
+82
View File
@@ -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
+159
View File
@@ -1,5 +1,8 @@
#include <unity.h>
#include <cmath>
#include "asteroids.h"
#include "mines.h"
#include "twenty48.h"
@@ -285,6 +288,153 @@ void test_2048_new_tiles_are_twos_and_sometimes_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);
@@ -308,5 +458,14 @@ int main() {
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();
}