Public Access
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:
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user