use bevy::prelude::*;
use bevy::window::WindowClosed;
use bevy::{audio::Volume, window::ExitCondition};
use rand::RngExt;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs};
const GRID_RADIUS: i32 = 7;
const MEDIUM_RADIUS: i32 = 3;
const HEX_SIZE: f32 = 28.0;
const COLLAPSE_DURATION: f32 = 0.45;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct Hex {
q: i32,
r: i32,
}
impl Hex {
fn new(q: i32, r: i32) -> Self {
Self { q, r }
}
fn add(self, other: Hex) -> Hex {
Hex::new(self.q + other.q, self.r + other.r)
}
fn rotate_right(self) -> Self {
// Axial-coordinate rotation around the origin.
Hex::new(-self.r, self.q + self.r)
}
fn distance(self, other: Hex) -> i32 {
let s1 = -self.q - self.r;
let s2 = -other.q - other.r;
((self.q - other.q).abs() + (self.r - other.r).abs() + (s1 - s2).abs()) / 2
}
}
#[derive(PartialEq)]
enum GamePhase {
Playing,
GameOver,
}
#[derive(Resource)]
struct GameState {
cells: HashMap<Hex, u32>,
pieces: Vec<Piece>,
selected_piece: usize,
score: u32,
dragging: bool,
drag_start: Vec2,
drag_position: Vec2,
drag_moved: bool,
phase: GamePhase,
}
impl GameState {
fn from_save(save: GameSave) -> GameState {
GameState {
cells: HashMap::from_iter(save.cells),
pieces: save.pieces,
selected_piece: 0,
score: save.score,
dragging: false,
drag_start: Vec2::ZERO,
drag_position: Vec2::ZERO,
drag_moved: false,
phase: GamePhase::Playing,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
struct Piece {
cells: Vec<Hex>,
color: Color,
}
#[derive(Component)]
struct ScoreText;
#[derive(Component)]
struct GameOverButton {
action: GameOverAction,
}
#[derive(Component, PartialEq)]
struct GameOverUI;
#[derive(Clone, Copy, PartialEq)]
enum GameOverAction {
Restart,
Quit,
}
#[derive(Resource, Default)]
struct CollapseAnimations {
active: Vec<CollapseAnimation>,
}
struct CollapseAnimation {
center: Hex,
cells: Vec<(Hex, Color)>,
elapsed: f32,
}
#[derive(Resource)]
struct GameSounds {
small_pop: Handle<AudioSource>,
big_pop: Handle<AudioSource>,
}
#[derive(Clone, Serialize, Deserialize)]
struct GameSave {
score: u32,
cells: Vec<(Hex, u32)>,
pieces: Vec<Piece>,
}
impl GameSave {
fn from_state(state: &GameState) -> GameSave {
GameSave {
score: state.score,
cells: state
.cells
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
pieces: state.pieces.clone(),
}
}
}
fn main() {
let game = load_game();
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Hex Collapse".into(),
resolution: (1100, 700).into(),
..default()
}),
exit_condition: ExitCondition::DontExit,
..default()
}))
.insert_resource(game)
.insert_resource(CollapseAnimations::default())
.add_systems(Startup, setup)
.add_systems(
Update,
(
handle_window_closed,
mouse_input,
draw_game,
update_score_text,
update_collapse_animations,
check_gameover,
button_hover_system,
draw_gameover,
)
.chain(),
)
.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Camera2d);
commands.spawn((
Text::new("Score: 0"),
TextFont {
font_size: FontSize::Px(32.0),
..default()
},
TextColor(Color::WHITE),
Node {
position_type: PositionType::Absolute,
left: Val::Px(25.0),
top: Val::Px(20.0),
..default()
},
ScoreText,
));
commands.insert_resource(GameSounds {
small_pop: asset_server.load("sounds/small_pop.ogg"),
big_pop: asset_server.load("sounds/big_pop.ogg"),
});
}
fn update_score_text(state: Res<GameState>, mut query: Query<&mut Text, With<ScoreText>>) {
if !state.is_changed() {
return;
}
for mut text in &mut query {
**text = format!("Score: {}", state.score);
}
}
fn check_gameover(mut state: ResMut<GameState>) {
if state.phase != GamePhase::Playing {
return;
}
let mut can_be_placed = false;
'outer: for q in -GRID_RADIUS..=GRID_RADIUS {
for r in -GRID_RADIUS..=GRID_RADIUS {
let grid_hex = Hex::new(q, r);
for i in 0..state.pieces.len() {
let mut piece = state.pieces[i].clone();
for _ in 0..6 {
rotate_piece(&mut piece);
let placed_cells: Vec<Hex> =
piece.cells.iter().map(|cell| grid_hex.add(*cell)).collect();
if can_place(&state.cells, &placed_cells) {
can_be_placed = true;
break 'outer;
}
}
}
}
}
if !can_be_placed {
state.phase = GamePhase::GameOver;
}
}
fn draw_gameover(
mut commands: Commands,
state: ResMut<GameState>,
ui_query: Query<Entity, With<GameOverUI>>,
) {
let ui = match ui_query.single() {
Ok(u) => Some(u),
Err(_) => None,
};
if state.phase == GamePhase::GameOver && ui == None {
// Container for buttons
commands
.spawn((
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
display: Display::Flex,
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgba(0.1, 0.1, 0.1, 0.8)),
GameOverUI,
))
.with_children(|parent| {
parent.spawn((
Text::new("GAME OVER"),
TextFont {
font_size: FontSize::Px(70.0),
..default()
},
TextColor(Color::srgb(1.0, 0.0, 0.0)),
));
parent.spawn((
Text::new(format!("Final Score: {}", state.score)),
TextFont {
font_size: FontSize::Px(48.0),
..default()
},
TextColor(Color::WHITE),
));
parent
.spawn((Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},))
.with_children(|child_node| {
child_node
.spawn((
Button,
Node {
width: Val::Px(200.0),
height: Val::Px(60.0),
display: Display::Flex,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(2.0)),
margin: UiRect {
left: (Val::Px(5.0)),
right: (Val::Px(5.0)),
top: (Val::Px(5.0)),
bottom: (Val::Px(5.0)),
},
..default()
},
BorderColor::all(Color::WHITE),
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
GameOverButton {
action: GameOverAction::Restart,
},
))
.with_children(|button| {
button.spawn((
Text::new("RESTART"),
TextFont {
font_size: FontSize::Px(32.0),
..default()
},
TextColor(Color::WHITE),
));
});
child_node
.spawn((
Button,
Node {
width: Val::Px(200.0),
height: Val::Px(60.0),
display: Display::Flex,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(2.0)),
margin: UiRect {
left: (Val::Px(5.0)),
right: (Val::Px(5.0)),
top: (Val::Px(5.0)),
bottom: (Val::Px(5.0)),
},
..default()
},
BorderColor::all(Color::WHITE),
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
GameOverButton {
action: GameOverAction::Quit,
},
))
.with_children(|button| {
button.spawn((
Text::new("QUIT"),
TextFont {
font_size: FontSize::Px(32.0),
..default()
},
TextColor(Color::WHITE),
));
});
});
});
} else if state.phase != GamePhase::GameOver {
match ui {
Some(u) => {
commands.entity(u).despawn_children();
commands.entity(u).despawn();
}
None => {}
}
}
}
fn mouse_input(
buttons: Res<ButtonInput<MouseButton>>,
windows: Query<(Entity, &Window)>,
camera_query: Query<(&Camera, &GlobalTransform)>,
mut state: ResMut<GameState>,
mut animations: ResMut<CollapseAnimations>,
mut commands: Commands,
sounds: Res<GameSounds>,
mut button_query: Query<(&Interaction, &GameOverButton), Changed<Interaction>>,
) {
let (window_entity, window) = match windows.single() {
Ok(w) => w,
Err(_) => return,
};
let Some(cursor) = window.cursor_position() else {
return;
};
let (camera, camera_transform) = match camera_query.single() {
Ok(c) => c,
Err(_) => return,
};
let Ok(world_position) = camera.viewport_to_world_2d(camera_transform, cursor) else {
return;
};
let scale_factor = calculate_scale_factor(window.width(), window.height());
let is_landscape = window.width() > window.height();
match state.phase {
GamePhase::Playing => {
let hex_size = HEX_SIZE * scale_factor;
let grid_origin = calculate_grid_origin(window.width(), window.height(), is_landscape);
if buttons.just_pressed(MouseButton::Left) {
state.drag_start = world_position;
state.drag_position = world_position;
state.dragging = true;
state.drag_moved = false;
let len = state.pieces.len();
// Clicking one of the pieces selects it.
for index in 0..len {
let position = piece_position(
index,
len,
window.width(),
window.height(),
scale_factor,
is_landscape,
);
if world_position.distance(position) < 70.0 {
state.selected_piece = index;
state.drag_start = world_position;
state.drag_position = world_position;
return;
}
}
}
if buttons.pressed(MouseButton::Left) && state.dragging {
state.drag_position = world_position;
if world_position.distance(state.drag_start) > 8.0 {
state.drag_moved = true;
}
}
if buttons.just_released(MouseButton::Left) && state.dragging {
let release_position = world_position;
let was_dragged = state.drag_moved;
state.dragging = false;
if !was_dragged {
// A click rotates the selected shape.
let selected_piece = state.selected_piece;
rotate_piece(&mut state.pieces[selected_piece]);
return;
}
let Some(anchor) = world_to_hex(release_position, hex_size, grid_origin) else {
return;
};
let piece = state.pieces[state.selected_piece].clone();
let placed_cells: Vec<Hex> =
piece.cells.iter().map(|cell| anchor.add(*cell)).collect();
if can_place(&state.cells, &placed_cells) {
let mut rng = rand::rng();
for cell in &placed_cells {
state.cells.insert(*cell, rng.random_range(1..=10));
}
commands.spawn((
AudioPlayer::new(sounds.small_pop.clone()),
PlaybackSettings::DESPAWN
.with_speed(rng.random_range(0.88..=1.16))
.with_volume(Volume::Linear(0.85)),
));
resolve_completed_hexagons(&mut state, &mut animations, &mut commands, &sounds);
let selected_piece = state.selected_piece;
state.pieces[selected_piece] = random_piece();
}
}
}
GamePhase::GameOver => {
// Handle button clicks
for (interaction, button) in &mut button_query {
match *interaction {
Interaction::Pressed => {
match button.action {
GameOverAction::Restart => {
reset_game(&mut state);
}
GameOverAction::Quit => {
// Close the app
commands.entity(window_entity).despawn();
}
}
}
_ => {}
}
}
}
}
}
fn rotate_piece(piece: &mut Piece) {
for cell in &mut piece.cells {
*cell = cell.rotate_right();
}
}
fn can_place(board: &HashMap<Hex, u32>, cells: &[Hex]) -> bool {
cells
.iter()
.all(|cell| cell.distance(Hex::new(0, 0)) <= GRID_RADIUS && !board.contains_key(cell))
}
fn resolve_completed_hexagons(
state: &mut GameState,
animations: &mut CollapseAnimations,
commands: &mut Commands,
sounds: &GameSounds,
) {
let centers: Vec<Hex> = state.cells.keys().copied().collect();
// Find every completed hexagon before changing state.cells.
let completed: Vec<(Hex, Vec<Hex>, u32)> = centers
.into_iter()
.filter_map(|center| {
let required_cells = medium_hexagon(center);
required_cells
.iter()
.all(|cell| state.cells.contains_key(cell))
.then(|| {
let gained = required_cells.iter().map(|cell| state.cells[cell]).sum();
(center, required_cells, gained)
})
})
.collect();
if completed.is_empty() {
return;
}
let gained = completed.iter().map(|(_, _, gained)| *gained).sum::<u32>();
state.score += gained * completed.len() as u32;
commands.spawn((
AudioPlayer::new(sounds.big_pop.clone()),
PlaybackSettings::DESPAWN
.with_speed(0.92)
.with_volume(Volume::Linear(1.0)),
));
// Capture animation data before removing any cells.
for (center, required_cells, _) in &completed {
let animated_cells: Vec<(Hex, Color)> = required_cells
.iter()
.filter_map(|cell| {
state
.cells
.get(cell)
.map(|value| (*cell, value_color(*value)))
})
.collect();
animations.active.push(CollapseAnimation {
center: *center,
cells: animated_cells,
elapsed: 0.0,
});
}
// Remove all cells belonging to all completed hexagons.
for (_, required_cells, _) in &completed {
for cell in required_cells {
state.cells.remove(cell);
}
}
// Insert every collapsed center after removals are complete.
for (center, _, gained) in completed {
state.cells.insert(center, gained);
}
// Continue resolving chains created by the collapse.
resolve_completed_hexagons(state, animations, commands, sounds);
}
fn medium_hexagon(center: Hex) -> Vec<Hex> {
let mut result = Vec::new();
for q in -MEDIUM_RADIUS + 1..=MEDIUM_RADIUS - 1 {
for r in -MEDIUM_RADIUS + 1..=MEDIUM_RADIUS - 1 {
let cell = center.add(Hex::new(q, r));
if cell.distance(center) < MEDIUM_RADIUS {
result.push(cell);
}
}
}
result
}
fn world_to_hex(position: Vec2, hex_size: f32, grid_origin: Vec2) -> Option<Hex> {
let local = position - grid_origin;
let q = (local.x / (hex_size * 1.5)).round();
let r = ((local.y - q * hex_size * 0.8660254) / (hex_size * 1.7320508)).round();
let hex = Hex::new(q as i32, r as i32);
if hex.distance(Hex::new(0, 0)) <= GRID_RADIUS {
Some(hex)
} else {
None
}
}
fn hex_to_world(hex: Hex, hex_size: f32, grid_origin: Vec2) -> Vec2 {
grid_origin
+ Vec2::new(
hex_size * 1.5 * hex.q as f32,
hex_size * 1.7320508 * (hex.r as f32 + hex.q as f32 * 0.5),
)
}
fn piece_position(
index: usize,
size: usize,
width: f32,
height: f32,
scale_factor: f32,
is_landscape: bool,
) -> Vec2 {
let midpoint = (size - 1) as f32 / 2.0;
let offset = index as f32 - midpoint;
if is_landscape {
// Vertical column on the left side
let piece_spacing = 200.0 * scale_factor;
Vec2::new(-1.0 * width * 0.3, 0.0 + (offset * piece_spacing))
} else {
// Horizontal row at the top
let piece_spacing = 170.0 * scale_factor;
Vec2::new(0.0 + (offset * piece_spacing), height * 0.3)
}
}
fn random_piece() -> Piece {
let mut rng = rand::rng();
let templates = [
vec![Hex::new(0, 0)], // 1 hex
vec![Hex::new(0, 0), Hex::new(1, 0)], // simple line
vec![Hex::new(0, 0), Hex::new(1, 0), Hex::new(0, 1)], // triangle
vec![Hex::new(0, 0), Hex::new(1, 0), Hex::new(-1, 0)], // double length line
vec![Hex::new(0, 0), Hex::new(1, 0), Hex::new(0, -1)], // C
vec![
Hex::new(0, 0),
Hex::new(1, 0),
Hex::new(-1, 0),
Hex::new(0, 1),
], // L
vec![
Hex::new(0, 0),
Hex::new(1, 0),
Hex::new(-1, 0),
Hex::new(1, -1),
], // Backwards L
vec![
Hex::new(0, 0),
Hex::new(1, 0),
Hex::new(-1, 0),
Hex::new(1, -1),
Hex::new(0, 1),
], // T
vec![
Hex::new(0, 0),
Hex::new(-1, 0),
Hex::new(1, 0),
Hex::new(0, -1),
Hex::new(1, -1),
], // Pentagon
vec![
Hex::new(0, 0),
Hex::new(1, 0),
Hex::new(-1, 0),
Hex::new(0, 1),
Hex::new(0, -1),
Hex::new(1, -1),
Hex::new(-1, 1),
], // Hexagon
];
let pindex = rng.random_range(0..templates.len());
let hue = rng.random_range(0.0..360.0);
let saturation = rng.random_range(0.75..1.0);
let value = rng.random_range(0.8..1.0);
let mut piece = Piece {
cells: templates[pindex].clone(),
color: Color::hsv(hue, saturation, value),
};
// Randomly rotate the new piece 0–3 times.
let rotations = rng.random_range(0..4);
for _ in 0..rotations {
rotate_piece(&mut piece);
}
piece
}
fn calculate_scale_factor(width: f32, height: f32) -> f32 {
// Scale based on the smaller dimension to ensure everything fits
let min_dimension = width.min(height);
// Adjust the divisor based on your target scale
(min_dimension / 800.0).max(0.5).min(2.0) // Clamp between 0.5x and 2.0x
}
fn calculate_grid_origin(width: f32, height: f32, is_landscape: bool) -> Vec2 {
if is_landscape {
Vec2::new(width * 0.2, 0.0)
} else {
Vec2::new(0.0, -1.0 * height * 0.1)
}
}
fn draw_game(
mut gizmos: Gizmos,
state: Res<GameState>,
mut animations: ResMut<CollapseAnimations>,
windows: Query<&Window>,
) {
let window = match windows.single() {
Ok(w) => w,
Err(_) => return,
};
let scale_factor = calculate_scale_factor(window.width(), window.height());
let is_landscape = window.width() > window.height();
let hex_size = HEX_SIZE * scale_factor;
let grid_origin = calculate_grid_origin(window.width(), window.height(), is_landscape);
// Draw the playable board
for q in -GRID_RADIUS..=GRID_RADIUS {
for r in -GRID_RADIUS..=GRID_RADIUS {
let hex = Hex::new(q, r);
if hex.distance(Hex::new(0, 0)) <= GRID_RADIUS {
let position = hex_to_world(hex, hex_size, grid_origin);
if let Some(color) = state.cells.get(&hex).map(|value| value_color(*value)) {
draw_hex(&mut gizmos, position, hex_size, color);
draw_hex(&mut gizmos, position, hex_size * 0.8, color);
// draw_hex(&mut gizmos, position, hex_size * 0.6, color);
} else {
draw_hex(
&mut gizmos,
position,
hex_size,
Color::srgb(0.50, 0.50, 0.50),
);
}
}
}
}
// Highlight the cells underneath the dragged piece
if state.dragging && state.drag_moved {
let piece = &state.pieces[state.selected_piece];
if let Some(anchor) = world_to_hex(state.drag_position, hex_size, grid_origin) {
let hovered_cells: Vec<Hex> =
piece.cells.iter().map(|cell| anchor.add(*cell)).collect();
let placement_is_valid = can_place(&state.cells, &hovered_cells);
let highlight_color = if placement_is_valid {
Color::srgba(1.0, 1.0, 1.0, 0.8)
} else {
Color::srgba(1.0, 0.0, 0.0, 0.8)
};
for hex in hovered_cells {
if hex.distance(Hex::new(0, 0)) <= GRID_RADIUS {
draw_hex(
&mut gizmos,
hex_to_world(hex, hex_size, grid_origin),
hex_size,
highlight_color,
);
}
}
}
}
let len = state.pieces.len();
let piece_hex_size = hex_size * 0.8;
// Draw the available pieces
for index in 0..len {
let piece = &state.pieces[index];
let origin = piece_position(
index,
len,
window.width(),
window.height(),
scale_factor,
is_landscape,
);
for cell in &piece.cells {
let position = origin + axial_offset(*cell, piece_hex_size);
draw_hex(&mut gizmos, position, piece_hex_size, piece.color);
}
}
// Draw the piece currently being dragged
if state.dragging && state.drag_moved {
let piece = &state.pieces[state.selected_piece];
for cell in &piece.cells {
let position = state.drag_position + axial_offset(*cell, hex_size);
draw_hex(
&mut gizmos,
position,
hex_size,
piece.color.with_alpha(0.75),
);
}
}
draw_collapse_animations(&mut gizmos, &mut animations, hex_size, grid_origin);
}
fn axial_offset(hex: Hex, size: f32) -> Vec2 {
Vec2::new(
size * 1.5 * hex.q as f32,
size * 1.7320508 * (hex.r as f32 + hex.q as f32 * 0.5),
)
}
fn draw_hex(gizmos: &mut Gizmos, center: Vec2, radius: f32, color: Color) {
let mut points = [Vec2::ZERO; 7];
for i in 0..6 {
let angle = std::f32::consts::PI / 3.0 * i as f32;
points[i] = center + Vec2::new(angle.cos(), angle.sin()) * radius;
}
points[6] = points[0];
for i in 0..6 {
gizmos.line_2d(points[i], points[i + 1], color);
}
}
fn value_color(value: u32) -> Color {
const MAX_VALUE: f32 = 10000.0;
let value = value.min(MAX_VALUE as u32) as f32;
let position = value / MAX_VALUE;
let stops: &[(f32, [f32; 3])] = &[
(0.00, [0.02, 0.05, 0.35]),
(0.01, [0.02, 0.38, 1.00]),
(0.02, [0.01, 0.40, 1.00]),
(0.03, [0.01, 0.42, 1.00]),
(0.04, [0.00, 0.44, 1.00]),
(0.05, [0.00, 0.46, 1.00]),
(0.06, [0.00, 0.49, 1.00]),
(0.07, [0.00, 0.52, 1.00]),
(0.08, [0.00, 0.55, 1.00]),
(0.09, [0.00, 0.58, 1.00]),
(0.10, [0.00, 0.61, 1.00]),
(0.11, [0.00, 0.64, 1.00]),
(0.12, [0.00, 0.67, 1.00]),
(0.13, [0.00, 0.70, 1.00]),
(0.14, [0.00, 0.72, 1.00]),
(0.15, [0.00, 0.75, 1.00]),
(0.16, [0.00, 0.78, 1.00]),
(0.17, [0.00, 0.81, 1.00]),
(0.18, [0.00, 0.84, 1.00]),
(0.19, [0.00, 0.86, 1.00]),
(0.20, [0.00, 0.88, 1.00]),
(0.21, [0.00, 0.90, 0.96]),
(0.22, [0.00, 0.92, 0.93]),
(0.23, [0.00, 0.93, 0.90]),
(0.24, [0.00, 0.94, 0.87]),
(0.25, [0.00, 0.95, 0.85]),
(0.26, [0.00, 0.95, 0.82]),
(0.27, [0.00, 0.95, 0.85]),
(0.28, [0.00, 0.95, 0.79]),
(0.29, [0.00, 0.95, 0.75]),
(0.30, [0.00, 0.95, 0.71]),
(0.31, [0.00, 0.95, 0.68]),
(0.32, [0.00, 0.95, 0.65]),
(0.33, [0.00, 0.95, 0.65]),
(0.34, [0.00, 0.94, 0.61]),
(0.35, [0.00, 0.93, 0.57]),
(0.36, [0.00, 0.92, 0.53]),
(0.37, [0.00, 0.91, 0.49]),
(0.38, [0.00, 0.90, 0.45]),
(0.39, [0.00, 0.86, 0.35]),
(0.40, [0.00, 0.90, 0.45]),
(0.41, [0.00, 0.87, 0.38]),
(0.42, [0.00, 0.84, 0.32]),
(0.43, [0.00, 0.81, 0.27]),
(0.44, [0.00, 0.80, 0.25]),
(0.45, [0.00, 0.82, 0.25]),
(0.46, [0.01, 0.84, 0.22]),
(0.47, [0.02, 0.86, 0.18]),
(0.48, [0.03, 0.88, 0.14]),
(0.49, [0.04, 0.89, 0.12]),
(0.50, [0.05, 0.90, 0.10]),
(0.51, [0.09, 0.91, 0.08]),
(0.52, [0.13, 0.92, 0.06]),
(0.53, [0.17, 0.93, 0.04]),
(0.54, [0.21, 0.94, 0.03]),
(0.55, [0.25, 0.95, 0.02]),
(0.56, [0.30, 0.96, 0.02]),
(0.57, [0.35, 0.97, 0.01]),
(0.58, [0.40, 0.98, 0.01]),
(0.59, [0.45, 0.99, 0.00]),
(0.60, [0.50, 1.00, 0.00]),
(0.61, [0.54, 1.00, 0.00]),
(0.62, [0.59, 1.00, 0.00]),
(0.63, [0.63, 1.00, 0.00]),
(0.64, [0.68, 1.00, 0.00]),
(0.65, [0.72, 1.00, 0.00]),
(0.66, [0.76, 1.00, 0.00]),
(0.67, [0.80, 1.00, 0.00]),
(0.68, [0.83, 1.00, 0.00]),
(0.69, [0.86, 1.00, 0.00]),
(0.70, [0.88, 1.00, 0.00]),
(0.71, [0.92, 0.99, 0.00]),
(0.72, [0.95, 0.98, 0.00]),
(0.73, [0.98, 0.97, 0.00]),
(0.74, [1.00, 0.96, 0.00]),
(0.75, [1.00, 0.95, 0.00]),
(0.76, [1.00, 0.92, 0.00]),
(0.77, [1.00, 0.89, 0.00]),
(0.78, [1.00, 0.86, 0.00]),
(0.79, [1.00, 0.84, 0.00]),
(0.80, [1.00, 0.82, 0.00]),
(0.81, [1.00, 0.77, 0.00]),
(0.82, [1.00, 0.73, 0.00]),
(0.83, [1.00, 0.70, 0.00]),
(0.84, [1.00, 0.68, 0.00]),
(0.85, [1.00, 0.61, 0.00]),
(0.86, [1.00, 0.56, 0.00]),
(0.87, [1.00, 0.52, 0.00]),
(0.88, [1.00, 0.47, 0.00]),
(0.89, [1.00, 0.42, 0.00]),
(0.90, [1.00, 0.38, 0.00]),
(0.91, [1.00, 0.30, 0.00]),
(0.92, [1.00, 0.22, 0.00]),
(0.93, [0.95, 0.14, 0.00]),
(0.94, [0.90, 0.06, 0.00]),
(0.95, [0.79, 0.03, 0.00]),
(0.96, [0.68, 0.00, 0.00]),
(1.0, [0.55, 0.00, 0.00]),
];
for window in stops.windows(2) {
let (mut start_position, start_color) = window[0];
let (mut end_position, end_color) = window[1];
start_position = start_position.powi(4);
end_position = end_position.powi(4);
if position <= end_position {
let range = end_position - start_position;
let interpolation = if range == 0.0 {
0.0
} else {
((position - start_position) / range).clamp(0.0, 1.0)
};
let r = lerp(start_color[0], end_color[0], interpolation);
let g = lerp(start_color[1], end_color[1], interpolation);
let b = lerp(start_color[2], end_color[2], interpolation);
return Color::srgb(r, g, b);
}
}
Color::BLACK
}
fn lerp(start: f32, end: f32, amount: f32) -> f32 {
start + (end - start) * amount
}
fn update_collapse_animations(time: Res<Time>, mut animations: ResMut<CollapseAnimations>) {
for animation in &mut animations.active {
animation.elapsed += time.delta_secs();
}
animations
.active
.retain(|animation| animation.elapsed < COLLAPSE_DURATION);
}
fn draw_collapse_animations(
gizmos: &mut Gizmos,
animations: &mut CollapseAnimations,
hex_size: f32,
grid_origin: Vec2,
) {
for animation in &mut animations.active {
let progress = (animation.elapsed / COLLAPSE_DURATION).clamp(0.0, 1.0);
// Ease-out curve: fast at first, slower near the end.
let eased = 1.0 - (1.0 - progress).powi(3);
// The completed hex shrinks and fades away.
let radius = HEX_SIZE * (1.0 - eased * 0.75);
let alpha = 1.0 - eased;
for (hex, color) in &animation.cells {
draw_hex(
gizmos,
hex_to_world(*hex, hex_size, grid_origin),
radius,
color.with_alpha(alpha),
);
}
// Pulse the resulting center hex after the collapse.
let pulse_start = 0.55;
if progress > pulse_start {
let pulse_progress = ((progress - pulse_start) / (1.0 - pulse_start)).clamp(0.0, 1.0);
let pulse = (pulse_progress * std::f32::consts::PI).sin();
let pulse_radius = HEX_SIZE * (1.0 + pulse * 0.35);
let pulse_alpha = pulse * 0.9;
draw_hex(
gizmos,
hex_to_world(animation.center, hex_size, grid_origin),
pulse_radius,
Color::WHITE.with_alpha(pulse_alpha),
);
}
}
}
fn handle_window_closed(
state: Res<GameState>,
mut closed: MessageReader<WindowClosed>,
mut exit: MessageWriter<AppExit>,
) {
for _ in closed.read() {
let _ = match save_game(&state) {
Ok(_) => {}
Err(e) => println!("{}", e),
};
exit.write(AppExit::Success);
}
}
fn save_game(state: &GameState) -> Result<(), Box<dyn std::error::Error>> {
let save = GameSave::from_state(state);
let json = serde_json::to_string_pretty(&save)?;
fs::write("save.json", json)?;
Ok(())
}
fn load_game() -> GameState {
let json = match fs::read_to_string("save.json") {
Ok(j) => j,
Err(_) => String::new(),
};
let save = match serde_json::from_str(&json) {
Ok(s) => s,
Err(_) => GameSave {
score: 0,
cells: Vec::new(),
pieces: vec![random_piece(), random_piece(), random_piece()],
},
};
GameState::from_save(save)
}
fn button_hover_system(
mut query: Query<
(&Interaction, &mut BackgroundColor),
(Changed<Interaction>, With<GameOverButton>),
>,
) {
for (interaction, mut bg_color) in &mut query {
match *interaction {
Interaction::Hovered => {
*bg_color = BackgroundColor(Color::srgb(0.4, 0.4, 0.4));
}
Interaction::None => {
*bg_color = BackgroundColor(Color::srgb(0.2, 0.2, 0.2));
}
Interaction::Pressed => {
*bg_color = BackgroundColor(Color::srgb(0.1, 0.1, 0.1));
}
}
}
}
fn reset_game(state: &mut GameState) {
// Clear all game entities
state.cells.clear();
state.pieces = vec![random_piece(); 3];
state.score = 0;
state.phase = GamePhase::Playing;
}