From af9077cb94322052b952e836f4f284db32632a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Thu, 15 Oct 2026 12:06:29 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- lib/games/src/asteroids.cpp | 180 +++++++++++++++++++++++ lib/games/src/asteroids.h | 82 +++++++++++ test/test_more_games/test_more_games.cpp | 159 ++++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 lib/games/src/asteroids.cpp create mode 100644 lib/games/src/asteroids.h diff --git a/lib/games/src/asteroids.cpp b/lib/games/src/asteroids.cpp new file mode 100644 index 0000000..4d9c4ba --- /dev/null +++ b/lib/games/src/asteroids.cpp @@ -0,0 +1,180 @@ +#include "asteroids.h" + +#include +#include + +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(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(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 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(r.size - 1))); + born.push_back(rock(r.x, r.y, static_cast(r.size - 1))); + } + rocks_.erase(rocks_.begin() + static_cast(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 diff --git a/lib/games/src/asteroids.h b/lib/games/src/asteroids.h new file mode 100644 index 0000000..d2838ab --- /dev/null +++ b/lib/games/src/asteroids.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include + +#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& rocks() const { return rocks_; } + const std::vector& 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& 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 rocks_; + std::vector 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 diff --git a/test/test_more_games/test_more_games.cpp b/test/test_more_games/test_more_games.cpp index 15f841d..8319b50 100644 --- a/test/test_more_games/test_more_games.cpp +++ b/test/test_more_games/test_more_games.cpp @@ -1,5 +1,8 @@ #include +#include + +#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(); }