use std::f32::consts::{FRAC_PI_2, TAU};
use sge::prelude::*;
const BASE_WINDOW_SIZE: Vec2 = Vec2::new(3840., 2160.);
const BASE_SCALE: f32 = 3.0;
const PLAYER_ACCEL: f32 = 0.5;
const PLAYER_TURN_SPEED: f32 = 0.1;
const PLAYER_SIZE: f32 = 10.0;
const FG: Color = Color::NEUTRAL_100;
const BG: Color = Color::NEUTRAL_950;
const RED: Color = Color::RED_500;
const PLAYER_LIN_DECEL: f32 = 0.9;
const PLAYER_ANG_DECEL: f32 = 0.9;
const LINE_THICKNESS: f32 = 1.0;
const MIN_ASTEROID_RADIUS: f32 = PLAYER_SIZE;
const MAX_ASTEROID_RADIUS: f32 = MIN_ASTEROID_RADIUS * 5.0;
const MAX_ASTEROID_SPEED: f32 = 1.0;
const MAX_ASTEROID_ANGVEL: f32 = 0.01;
const MAX_ASTEROID_POINT_OFFSET: f32 = 0.5;
const MAX_ASTEROID_POINTS: f32 = 0.5;
const NUM_ASTEROIDS: usize = 10;
const MAX_PLAYER_HEALTH: usize = 3;
const PLAYER_HEALTHBAR_WIDTH: f32 = 400.0;
const PLAYER_HEALTHBAR_HEIGHT: f32 = 50.0;
const PLAYER_HEALTHBAR_PADDING: f32 = 10.0;
const BULLET_SPEED: f32 = 10.0;
const BULLET_RADIUS: f32 = 2.0;
const ASTEROID_BLINK_DURATION: f32 = 0.15;
const PLAYER_INVINCIBILITY_DURATION: f32 = 1.0;
actions! {
UP, DOWN, LEFT, RIGHT, SHOOT
}
struct State {
player: Player,
asteroids: Vec<Asteroid>,
score: usize,
}
impl State {
fn new() -> Self {
Self {
player: Player::new(),
asteroids: (0..NUM_ASTEROIDS).map(|_| Asteroid::new()).collect(),
score: 0,
}
}
fn update(&mut self) {
if self.player.health != 0 {
self.game_update();
}
}
fn draw_endscreen(&self) {
let text = format!("GAME OVER!\n\nScore: {}", self.score);
let mut params = TextDrawParams {
font_size: 30,
..Default::default()
};
let dimensions = measure_multiline_text_ex(&text, params, 1.5);
params.position = window_size() / 2.0 - dimensions.size / 2.0;
draw_multiline_text_ex(text, params, 1.5);
}
fn game_update(&mut self) {
self.player.update();
for asteroid in self.asteroids.iter_mut() {
asteroid.update();
}
let mut bullets_to_remove = vec![];
for (bi, bullet) in self.player.bullets.iter().enumerate() {
for asteroid in self.asteroids.iter_mut() {
let dist = bullet.pos.distance(asteroid.pos);
if dist <= asteroid.radius + BULLET_RADIUS && asteroid.health != 0 {
let impact_dir = (bullet.pos - asteroid.pos).normalize_or_zero();
spawn_hit_particles(bullet.pos, impact_dir);
asteroid.health = asteroid.health.saturating_sub(1);
asteroid.last_hit = time();
if asteroid.health == 0 {
spawn_death_particles(asteroid.pos, asteroid.radius);
}
bullets_to_remove.push(bi);
break;
}
}
}
bullets_to_remove.sort_unstable();
bullets_to_remove.dedup();
for bi in bullets_to_remove.into_iter().rev() {
self.player.bullets.remove(bi);
}
for asteroid in &self.asteroids {
if asteroid.health == 0 && asteroid.last_hit < time() - ASTEROID_BLINK_DURATION {
self.score += asteroid.max_health;
}
}
self.asteroids
.retain(|a| a.health > 0 || a.last_hit > time() - ASTEROID_BLINK_DURATION);
let now = time();
if now - self.player.last_hit > PLAYER_INVINCIBILITY_DURATION {
for asteroid in &self.asteroids {
let dist = self.player.pos.distance(asteroid.pos);
if dist <= asteroid.radius + PLAYER_SIZE {
self.player.health = self.player.health.saturating_sub(1);
self.player.last_hit = now;
if self.player.health == 0 {
play_sound(get_sounds().game_over);
} else {
play_sound(get_sounds().player_hit);
}
break;
}
}
}
if rand_ratio(1, 100) {
self.asteroids.push(Asteroid::new());
}
}
fn draw(&self) {
if self.player.health != 0 {
draw_text(format!("Score: {}", self.score), Vec2::splat(10.0));
self.player.draw();
for asteroid in &self.asteroids {
asteroid.draw();
}
} else {
self.draw_endscreen();
}
}
}
struct Bullet {
pos: Vec2,
vel: Vec2,
}
impl Bullet {
fn new(pos: Vec2, rotation: f32, player_vel: Vec2) -> Self {
let dir = Vec2::new(rotation.sin(), rotation.cos());
Self {
pos,
vel: dir * BULLET_SPEED + player_vel,
}
}
fn update(&mut self) -> bool {
self.pos += self.vel;
let half_w = screen_distance_to_world(window_width()) / 2.0;
let half_h = screen_distance_to_world(window_height()) / 2.0;
self.pos.x.abs() <= half_w && self.pos.y.abs() <= half_h
}
fn draw(&self) {
draw_circle_world(self.pos, BULLET_RADIUS, FG);
}
}
struct Player {
pos: Vec2,
rotation: f32,
health: usize,
last_hit: f32,
speed: f32,
angvel: f32,
bullets: Vec<Bullet>,
}
impl Player {
fn new() -> Self {
Self {
pos: Vec2::ZERO,
rotation: 0.0,
health: MAX_PLAYER_HEALTH,
last_hit: f32::MIN,
speed: 0.0,
angvel: 0.0,
bullets: vec![],
}
}
fn vel(&self) -> Vec2 {
self.speed * Vec2::new(self.rotation.sin(), self.rotation.cos())
}
fn update(&mut self) {
self.update_input();
self.update_velocities();
self.pos = wrap_position(self.pos, PLAYER_SIZE);
for bullet in self.bullets.iter_mut() {
bullet.update();
}
}
fn emit_movement_particles(&self) {
let batch = ParticleBatch::builder()
.shape(Circle::new(
Vec2::ZERO,
Vec2::splat(LINE_THICKNESS * 1.5),
FG,
))
.end_color(RED.with_alpha(0.0))
.speed(self.speed * 0.8)
.direction(-self.rotation - FRAC_PI_2)
.direction_randomness(0.2)
.speed_randomness(self.speed * 0.5)
.lifetime(0.8)
.lifetime_randomness(0.4)
.quantity((self.speed * 5.0).max(1.0) as usize)
.position_randomness(Vec2::splat(PLAYER_SIZE * 0.3))
.build();
get_particles().spawn_batch(
&batch,
self.pos + vec2(0.0, -PLAYER_SIZE * 0.3).rotated_around_origin(-self.rotation),
);
}
fn update_input(&mut self) {
if action_held(UP) {
self.speed += PLAYER_ACCEL;
self.emit_movement_particles();
if once_per_n_seconds(0.1) {
play_sound_ex(get_sounds().engine).volume(0.1).start();
}
}
if action_held(DOWN) {
self.speed -= PLAYER_ACCEL;
}
if action_held(RIGHT) {
self.angvel -= PLAYER_TURN_SPEED;
}
if action_held(LEFT) {
self.angvel += PLAYER_TURN_SPEED;
}
if action_pressed(SHOOT) {
play_sound(get_sounds().shoot);
let tip = self.pos + vec2(0.0, PLAYER_SIZE).rotated_around_origin(-self.rotation);
self.bullets
.push(Bullet::new(tip, self.rotation, self.vel()));
}
}
fn update_velocities(&mut self) {
self.pos += self.vel();
self.rotation += self.angvel;
self.speed *= PLAYER_LIN_DECEL;
self.angvel *= PLAYER_ANG_DECEL;
if self.speed.abs() < 0.1 {
self.speed = 0.0;
}
if self.angvel.abs() < 0.1 {
self.angvel = 0.0;
}
}
fn draw(&self) {
for bullet in &self.bullets {
bullet.draw();
}
let c = self.pos;
let tip = c + vec2(0.0, PLAYER_SIZE);
let elbow = c + vec2(0.0, -PLAYER_SIZE * 0.5);
let left_wing = c + vec2(-PLAYER_SIZE, -PLAYER_SIZE);
let right_wing = c + vec2(PLAYER_SIZE, -PLAYER_SIZE);
let now = time();
let time = (now - self.last_hit) * 10.0;
let blink_on = time as i32 % 2 == 0;
let currently_invincible = now - self.last_hit < PLAYER_INVINCIBILITY_DURATION;
let color = if currently_invincible && blink_on {
FG.with_alpha(0.3)
} else {
FG
};
if currently_invincible && blink_on {
vignette_screen(RED, 0.5);
}
draw_circle_path_world(
&[tip, left_wing, elbow, right_wing, tip]
.map(|p| p.rotated_around_point(c, -self.rotation)),
LINE_THICKNESS,
color,
);
self.draw_healthbar();
}
fn draw_healthbar(&self) {
let width = PLAYER_HEALTHBAR_WIDTH / MAX_PLAYER_HEALTH as f32;
let height = PLAYER_HEALTHBAR_HEIGHT;
for i in 0..self.health {
let offset = (width + PLAYER_HEALTHBAR_PADDING) * i as f32 + PLAYER_HEALTHBAR_PADDING;
draw_rect(
vec2(offset, window_height() - height - PLAYER_HEALTHBAR_PADDING),
vec2(width, height),
FG,
);
}
}
}
struct Asteroid {
health: usize,
max_health: usize,
last_hit: f32,
points: Vec<Vec2>,
pos: Vec2,
velocity: Vec2,
rotation: f32,
angvel: f32,
radius: f32,
}
impl Asteroid {
fn new() -> Self {
let radius = rand_range(MIN_ASTEROID_RADIUS..MAX_ASTEROID_RADIUS);
let health = (radius / MIN_ASTEROID_RADIUS) as usize * 2;
let last_hit = f32::MIN;
let pos = rand_vec2() * BASE_WINDOW_SIZE / 2.0;
let velocity = rand_vec2() * MAX_ASTEROID_SPEED;
let rotation = rand_f32() * TAU;
let angvel = rand_f32() * MAX_ASTEROID_ANGVEL;
let num_points = (radius * MAX_ASTEROID_POINTS) as usize;
let max_offset = radius * MAX_ASTEROID_POINT_OFFSET / 2.0;
let mut points = Vec::with_capacity(num_points + 1);
for i in 0..num_points {
let angle = (TAU / num_points as f32) * i as f32;
let offset = rand_range(-max_offset..max_offset) + radius;
let pos = Vec2::new(0.0, offset).rotated_around_origin(angle);
points.push(pos);
}
points.push(points[0]);
Self {
health,
last_hit,
points,
pos,
velocity,
rotation,
angvel,
radius,
max_health: health,
}
}
fn update(&mut self) {
self.pos += self.velocity;
self.rotation += self.angvel;
self.pos = wrap_position(self.pos, self.radius);
}
fn draw(&self) {
let points = &self
.points
.iter()
.map(|p| p.rotated_around_origin(self.rotation) + self.pos)
.collect::<Vec<_>>();
let blinking = time() - self.last_hit < ASTEROID_BLINK_DURATION;
let fill = if blinking { FG } else { BG };
draw_custom_shape_world(points.clone(), fill);
draw_circle_path_world(points, LINE_THICKNESS, FG);
}
}
struct Sounds {
shoot: SoundRef,
asteroid_hit: SoundRef,
asteroid_death: SoundRef,
player_hit: SoundRef,
game_over: SoundRef,
engine: SoundRef,
}
impl Sounds {
fn new() -> Result<Self, SoundLoadError> {
Ok(Self {
shoot: include_sound!("../assets/sounds/Gun.wav")?,
asteroid_hit: include_sound!("../assets/sounds/Click.wav")?,
asteroid_death: include_sound!("../assets/sounds/Explosion.wav")?,
player_hit: include_sound!("../assets/sounds/Hurt.wav")?,
game_over: include_sound!("../assets/sounds/Powerdown.wav")?,
engine: include_sound!("../assets/sounds/Crunch.wav")?,
})
}
}
fn main() -> anyhow::Result<()> {
init("Space game")?;
storage_store_state(ParticleSystem::new());
storage_store_state(Sounds::new()?);
bind! {
UP => KeyCode::ArrowUp;
UP => KeyCode::KeyW;
DOWN => KeyCode::ArrowDown;
DOWN => KeyCode::KeyS;
LEFT => KeyCode::ArrowLeft;
LEFT => KeyCode::KeyA;
RIGHT => KeyCode::ArrowRight;
RIGHT => KeyCode::KeyD;
SHOOT => KeyCode::Space;
}
let mut state = State::new();
loop {
clear_screen(BG);
let scale = window_size() / BASE_WINDOW_SIZE * BASE_SCALE;
let scale = scale.x.min(scale.y);
get_camera_2d_mut().set_scale(scale);
state.update();
get_particles().update();
get_particles().draw_world();
state.draw();
if should_quit() {
break;
}
next_frame();
}
Ok(())
}
fn get_particles() -> &'static mut ParticleSystem {
storage_get_state_mut()
}
fn get_sounds() -> &'static Sounds {
storage_get_state()
}
fn spawn_hit_particles(pos: Vec2, impact_dir: Vec2) {
play_sound(get_sounds().asteroid_hit);
let batch = ParticleBatch::builder()
.shape(Circle::new(
Vec2::ZERO,
Vec2::splat(LINE_THICKNESS * 1.5),
FG.with_alpha(0.5),
))
.end_color(FG.with_alpha(0.0))
.speed(3.0)
.direction(impact_dir.to_angle())
.direction_randomness(1.5)
.speed_randomness(2.0)
.lifetime(0.4)
.lifetime_randomness(0.2)
.quantity(8)
.build();
get_particles().spawn_batch(&batch, pos);
}
fn spawn_death_particles(pos: Vec2, radius: f32) {
play_sound(get_sounds().asteroid_death);
let batch = ParticleBatch::builder()
.shape(Circle::new(
Vec2::ZERO,
Vec2::splat(LINE_THICKNESS * 2.0),
FG.with_alpha(0.7),
))
.end_color(FG.with_alpha(0.0))
.speed(radius * 0.1)
.direction(0.0)
.direction_randomness(std::f32::consts::TAU)
.speed_randomness(radius * 0.2)
.lifetime(0.8)
.lifetime_randomness(0.4)
.quantity((radius * 1.5) as usize)
.position_randomness(Vec2::splat(radius * 0.5))
.build();
get_particles().spawn_batch(&batch, pos);
}
fn wrap_position(pos: Vec2, size: f32) -> Vec2 {
let half_w = screen_distance_to_world(window_width()) / 2.0 + size;
let half_h = screen_distance_to_world(window_height()) / 2.0 + size;
let x = if pos.x > half_w {
-half_w
} else if pos.x < -half_w {
half_w
} else {
pos.x
};
let y = if pos.y > half_h {
-half_h
} else if pos.y < -half_h {
half_h
} else {
pos.y
};
Vec2::new(x, y)
}