feat: Implement background shader and visual effects

- Added a new WGSL shader for background rendering with volumetric fog effects.
- Created components for game entities including Circle, Velocity, Explosion, and ChainReaction.
- Introduced game state management with GameState enum and LevelState resource.
- Implemented event system for explosion requests and circle destruction notifications.
- Developed plugins for game logic, input handling, movement, reactions, scoring, and UI.
- Configured game settings through GameConfig resource with adjustable parameters.
- Enhanced UI with configuration options and game over screens.
- Integrated spatial grid for efficient collision detection and explosion handling.
This commit is contained in:
2026-01-04 04:42:43 +01:00
commit 666df8f892
20 changed files with 7110 additions and 0 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.wasm32-unknown-unknown]
runner = "wasm-server-runner"

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

5504
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

37
Cargo.toml Normal file
View File

@@ -0,0 +1,37 @@
[package]
name = "chain-reaction"
version = "0.1.0"
edition = "2024"
[dependencies]
rand = "0.9.2"
[dependencies.bevy]
version = "0.17.3"
default-features = true
[profile.dev]
opt-level = 1
# Enable a large amount of optimization in the dev profile for dependencies.
[profile.dev.package."*"]
opt-level = 3
# Enable more optimization in the release profile at the cost of compile time.
[profile.release]
# Compile the entire crate as one unit.
# Slows compile times, marginal improvements.
codegen-units = 1
# Do a second optimization pass over the entire program, including dependencies.
# Slows compile times, marginal improvements.
lto = "thin"
# Optimize for size in the wasm-release profile to reduce load times and bandwidth usage on web.
[profile.wasm-release]
# Default to release profile values.
inherits = "release"
# Optimize with size in mind (also try "z", sometimes it is better).
# Slightly slows compile times, great improvements to file size and runtime performance.
opt-level = "s"
# Strip all debugging information from the binary to slightly reduce file size.
strip = "debuginfo"

86
GDD.md Normal file
View File

@@ -0,0 +1,86 @@
## **Chain Reaction - Game Design Document**
### **Core Concept**
Players click once to create a static explosion that destroys floating circles. The goal is to clear the screen with one strategic click, triggering chain reactions of destruction.
### **Gameplay Mechanics**
**Primary Mechanic**:
- Click anywhere to create explosion at position
- Explosions destroy circles within radius
- Destroyed circles trigger secondary explosions (chain reaction)
- Game ends when all circles are destroyed or time expires
**Circle Properties**:
- Random radii (10-50px)
- Random alpha transparency (0.3-1.0)
- Random colors (pastel palette)
- Physics: Smooth movement, collision detection
**Explosion Mechanics**:
- Static explosion at click position
- Explosion radius = 100px (tunable)
- Chain reaction: Destroyed circles explode after 0.1s delay
- Secondary explosions have 50% smaller radius
### **Core Loop**
**30-second gameplay cycle**:
- Start with 20-30 circles
- Player clicks strategically to create chain reactions
- Game ends when all circles destroyed OR time runs out (30 seconds)
- Win condition: Clear screen before time expires
### **Scoring System**
- Points = circles destroyed × multiplier
- Multiplier increases with consecutive chain reactions (1x, 2x, 4x, 8x...)
- Bonus points for clearing all circles in single click
### **Visual Design**
**Art Style**: Minimalist circles with:
- Pastel color palette (blues, purples, pinks)
- Alpha transparency (0.3-1.0)
- Smooth movement animations
- Particle effects on destruction
**UI Elements**:
- Score display
- Timer countdown
- "Chain Reaction" indicator
- Restart button
### **Technical Requirements**
**Spatial Partitioning**: Grid-based system for efficient collision detection
**Performance**: Handle 500+ circles with 60fps target
**Physics**: Smooth movement, realistic collision response
### **Game States**
1. **Menu**: Start screen, instructions, high score
2. **Playing**: Main gameplay loop
3. **GameOver**: Score display, restart option
### **Implementation Roadmap**
1. Basic window + circle generation (5min)
2. Click detection + explosion mechanics (10min)
3. Chain reaction logic + destruction system (15min)
4. Scoring + game states (10min)
5. Polish + optimization (20min)
### **Optional Enhancements**
- Power-ups (larger explosions, slower circles)
- Different circle types (fast/slow, larger/smaller)
- Time pressure mechanic
- High score system with local persistence

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

113
README.md Normal file
View File

@@ -0,0 +1,113 @@
# Chain Reaction
A minimalist physics-based puzzle game where strategic clicks create explosive chain reactions. Clear the screen with one perfect click!
![License](https://img.shields.io/badge/license-MIT-blue.svg)
## About
Chain Reaction is a fast-paced arcade game built with Rust and Bevy. Players must strategically place a single explosion to destroy floating circles, triggering chain reactions that clear the entire screen. Each game is a 30-second challenge to maximize your score through clever positioning and timing.
## Features
- **One-Click Gameplay**: Simple to learn, challenging to master
- **Chain Reaction Mechanics**: Destroyed circles trigger secondary explosions
- **Dynamic Scoring**: Multiplier system rewards consecutive chain reactions
- **Minimalist Design**: Beautiful pastel color palette with smooth animations
- **Physics Simulation**: Realistic circle movement and collision detection
- **Performance Optimized**: Handles 500+ circles at 60fps
## Getting Started
### Prerequisites
- [Rust](https://rustup.rs/) (1.70.0 or later)
- Cargo (comes with Rust)
### Installation
```bash
# Clone the repository
git clone https://github.com/yourusername/chain-reaction.git
cd chain-reaction
# Build and run
cargo run --release
```
### Development Build
```bash
# Run in debug mode with faster compile times
cargo run
```
## How to Play
1. **Objective**: Clear all circles from the screen before time runs out (30 seconds)
2. **Click** anywhere to create an explosion
3. **Strategy**: Position your click to maximize the chain reaction
4. **Scoring**:
- Base points for each destroyed circle
- Multiplier increases with consecutive chain reactions (1x → 2x → 4x → 8x...)
- Bonus points for clearing all circles in a single click
### Controls
- **Left Mouse Button**: Create explosion
## Game Mechanics
### Circles
- Random sizes (10-50px radius)
- Pastel color palette
- Variable transparency (0.3-1.0)
- Smooth physics-based movement
### Explosions
- Initial explosion radius: 100px
- Chain reaction delay: 0.1s
- Secondary explosions: 50% smaller radius
- Cascading destruction creates satisfying combos
## Technical Details
**Built With:**
- [Rust](https://www.rust-lang.org/) - Systems programming language
- [Bevy](https://bevyengine.org/) - Data-driven game engine
- Spatial partitioning for efficient collision detection
## Roadmap
See [GDD.md](GDD.md) for the complete game design document.
### Potential Enhancements
- [ ] Power-ups (larger explosions, time freeze)
- [ ] Different circle types (fast/slow, bonus circles)
- [ ] High score persistence
- [ ] Multiple difficulty levels
- [ ] Sound effects and music
## Contributing
Contributions are welcome! Feel free to:
- Report bugs
- Suggest new features
- Submit pull requests
## License
See [LICENSE](LICENSE) for details.
## Acknowledgments
- Built with [Bevy Engine](https://bevyengine.org/)
---
**Enjoy creating explosive chain reactions!**

View File

@@ -0,0 +1,169 @@
#import bevy_sprite::mesh2d_view_bindings::globals
struct BackgroundMaterial {
resolution: vec2<f32>,
time_offset: f32,
}
@group(2) @binding(0) var<uniform> material: BackgroundMaterial;
// --- CONFIGURATION ---
const RAYS_COUNT: i32 = 32; // Increase to 64 if on PC for smoother quality
const RENDER_DISTANCE: f32 = 5.0; // How far rays travel
const NEAR_PLANE: f32 = 1.5; // Higher = Zoomed in (Less "Fisheye/Disco")
const ROTATION_SPEED: f32 = 0.1;
const JITTERING: f32 = 0.05;
// Colors
const COLOR1: vec3<f32> = vec3<f32>(0.1, 0.0, 0.3); // Deep Nebula Purple
const COLOR2: vec3<f32> = vec3<f32>(0.8, 0.2, 0.5); // Hot Pink Highlights
// --- NOISE FUNCTIONS ---
fn hash(v: vec3<f32>) -> f32 {
return fract(sin(dot(v, vec3<f32>(11.51721, 67.12511, 9.7561))) * 1551.4172);
}
fn getNoiseFromVec3(v: vec3<f32>) -> f32 {
let rootV = floor(v);
let f = smoothstep(vec3<f32>(0.0), vec3<f32>(1.0), fract(v));
let n000 = hash(rootV);
let n001 = hash(rootV + vec3<f32>(0.0, 0.0, 1.0));
let n010 = hash(rootV + vec3<f32>(0.0, 1.0, 0.0));
let n011 = hash(rootV + vec3<f32>(0.0, 1.0, 1.0));
let n100 = hash(rootV + vec3<f32>(1.0, 0.0, 0.0));
let n101 = hash(rootV + vec3<f32>(1.0, 0.0, 1.0));
let n110 = hash(rootV + vec3<f32>(1.0, 1.0, 0.0));
let n111 = hash(rootV + vec3<f32>(1.0, 1.0, 1.0));
let n = mix(vec4<f32>(n000, n010, n100, n110), vec4<f32>(n001, n011, n101, n111), f.z);
let n_xy = mix(vec2<f32>(n.x, n.z), vec2<f32>(n.y, n.w), f.y);
return mix(n_xy.x, n_xy.y, f.x);
}
fn volumetricFog(v: vec3<f32>, noiseMod: f32) -> f32 {
var noise: f32 = 0.0;
var alpha: f32 = 1.0;
var point: vec3<f32> = v;
// 5 Octaves of noise
for(var i: i32 = 0; i < 5; i++) {
noise += getNoiseFromVec3(point) * alpha;
point *= 2.0;
alpha *= 0.5;
}
noise *= 0.575;
// Add time-based movement to edges
let edge = 0.1 + getNoiseFromVec3(v * 0.5 + vec3<f32>(globals.time * 0.03)) * 0.8;
// Apply "Beat" modulation (noiseMod)
let modNoise = (0.5 - abs(edge * (1.0 + noiseMod * 0.05) - noise)) * 2.0;
// Contrast Curve
return (smoothstep(0.0, 0.9, modNoise * modNoise) + (1.0 - smoothstep(1.3, 0.6, modNoise))) * 0.2;
}
fn fogMarch(rayStart: vec3<f32>, rayDirection: vec3<f32>, disMod: f32) -> vec3<f32> {
var stepLength = RENDER_DISTANCE / f32(RAYS_COUNT);
var fog = vec3<f32>(0.0);
var point = rayStart;
for(var i: i32 = 0; i < RAYS_COUNT; i++) {
point += rayDirection * stepLength;
fog += volumetricFog(point, disMod)
* mix(COLOR1, COLOR2 * (1.0 + disMod * 0.5), getNoiseFromVec3((point + vec3<f32>(12.51, 52.167, 1.146)) * 0.5))
* getNoiseFromVec3(point * 0.2 + 20.0) * 2.0; // "Holes" in fog
stepLength *= 1.05; // Exponential step (fewer samples close up)
}
// "Dither" the end result by the far-plane noise to hide banding
let farNoise = pow(getNoiseFromVec3((rayStart + rayDirection * RENDER_DISTANCE)), 2.0);
fog = (fog / f32(RAYS_COUNT)) * (farNoise * 3.0 + disMod * 0.5);
return fog;
}
@fragment
fn fragment(
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
) -> @location(0) vec4<f32> {
let time = globals.time + material.time_offset;
// 1. Aspect Ratio Correction
// Map UVs from 0..1 to -1..1
let centered = (uv - 0.5) * 2.0;
// Fix aspect ratio by scaling Y (Width is dominant)
let aspect_y = material.resolution.y / material.resolution.x;
let final_uv = vec2<f32>(centered.x, centered.y * aspect_y);
// 2. Camera Rotation Angles
let angleY = sin(time * ROTATION_SPEED * 2.0);
let angleX = cos(time * 0.712 * ROTATION_SPEED);
let angleZ = sin(time * 1.779 * ROTATION_SPEED);
// 3. Rotation Matrices - COPIED EXACTLY FROM ORIGINAL GLSL
// The original author used a non-standard axis alignment (sin on diagonal).
// We construct these column-by-column to match GLSL memory layout.
// GLSL: mat3(1, 0, 0, 0, sin, cos, 0, -cos, sin)
let rotX = mat3x3<f32>(
vec3<f32>(1.0, 0.0, 0.0), // Col 0
vec3<f32>(0.0, sin(angleX), cos(angleX)), // Col 1
vec3<f32>(0.0, -cos(angleX), sin(angleX)) // Col 2
);
// GLSL: mat3(sin, cos, 0, -cos, sin, 0, 0, 0, 1)
let rotZ = mat3x3<f32>(
vec3<f32>(sin(angleZ), cos(angleZ), 0.0), // Col 0
vec3<f32>(-cos(angleZ), sin(angleZ), 0.0), // Col 1
vec3<f32>(0.0, 0.0, 1.0) // Col 2
);
// GLSL: mat3(sin, 0, cos, 0, 1, 0, -cos, 0, sin)
let rotY = mat3x3<f32>(
vec3<f32>(sin(angleY), 0.0, -cos(angleY)), // Col 0
vec3<f32>(0.0, 1.0, 0.0), // Col 1
vec3<f32>(cos(angleY), 0.0, sin(angleY)) // Col 2
);
// 4. Ray Construction
// Combine rotations
let rotation = rotY * rotZ * rotX;
// Forward movement (breathing effect)
let near_plane_mod = NEAR_PLANE * (1.0 + sin(time * 0.2) * 0.4);
// Original Vector: X=Right, Y=Forward (Depth), Z=Vertical
let ray_dir_local = normalize(vec3<f32>(final_uv.x, near_plane_mod, final_uv.y));
// Apply Rotation
let rayDirection = rotation * ray_dir_local;
// 5. Camera Position
let cameraCenter = vec3<f32>(
sin(time * 0.2) * 10.0,
time * 0.2 * 10.0,
cos(time * 0.78 * 0.2 + 2.14) * 10.0
);
// 6. Ray Start with Jitter
let jitter = (hash(vec3<f32>(final_uv + 4.0, fract(time) + 2.0)) - 0.5) * JITTERING;
let rayStart = cameraCenter + rayDirection * jitter;
// 7. Render
// Fake "Audio Reactivity" using sine wave
let beat = smoothstep(0.6, 0.9, pow(sin(time * 4.0) * 0.5 + 0.5, 2.0) * 0.06);
var fog = fogMarch(rayStart, rayDirection, beat);
// Post-Process
fog *= 2.5 * 1.2; // Brightness
fog += 0.07 * mix(COLOR1, COLOR2, 0.5); // Base Color
// Tone mapping to prevent burnout
fog = sqrt(smoothstep(vec3<f32>(0.0), vec3<f32>(1.5), fog));
return vec4<f32>(fog, 1.0);
}

127
src/components.rs Normal file
View File

@@ -0,0 +1,127 @@
use bevy::{platform::collections::HashMap, prelude::*};
#[derive(Component)]
pub struct Circle {
pub radius: f32,
}
#[derive(Component)]
pub struct Velocity(pub Vec2);
#[derive(Component)]
pub struct Explosion {
pub timer: Timer,
pub radius: f32,
}
#[derive(Component)]
pub struct ChainReaction {
pub delay: Timer,
pub next_radius: f32,
}
#[derive(Resource)]
pub struct Score {
pub total: u32,
pub multiplier: u32,
pub combo_timer: Timer,
}
#[derive(States, Default, Clone, PartialEq, Eq, Hash, Debug)]
pub enum GameState {
#[default]
Menu,
Playing,
GameOver,
}
#[derive(Resource, Default)]
pub struct SpatialGrid {
pub cells: HashMap<IVec2, Vec<Entity>>,
pub cell_size: f32,
}
impl SpatialGrid {
pub fn pos_to_cell(&self, pos: Vec2) -> IVec2 {
IVec2::new(
(pos.x / self.cell_size).floor() as i32,
(pos.y / self.cell_size).floor() as i32,
)
}
pub fn clear(&mut self) {
self.cells.clear();
}
}
#[derive(Component)]
pub struct MainCamera;
#[derive(Resource, Clone, Reflect)]
#[reflect(Resource)]
pub struct GameConfig {
pub circle_count_min: u32,
pub circle_count_max: u32,
pub circle_radius_min: f32,
pub circle_radius_max: f32,
pub max_velocity: f32,
pub explosion_radius: f32,
pub explosion_duration: f32,
pub chain_reaction_delay: f32,
pub secondary_explosion_factor: f32,
pub base_score: u32,
pub combo_window: f32,
pub cell_size: f32,
pub manual_explosion_limit: u32,
}
impl Default for GameConfig {
fn default() -> Self {
Self {
circle_count_min: 1000,
circle_count_max: 5000,
circle_radius_min: 10.0,
circle_radius_max: 50.0,
max_velocity: 1000.0,
explosion_radius: 100.0,
explosion_duration: 0.5,
chain_reaction_delay: 0.1,
secondary_explosion_factor: 0.5,
base_score: 10,
combo_window: 0.5,
cell_size: 100.0,
manual_explosion_limit: 1,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum LevelStatus {
#[default]
Playing,
Victory,
Defeat(usize),
}
#[derive(Resource)]
pub struct LevelState {
pub explosions_left: u32,
pub settle_timer: Timer,
pub status: LevelStatus,
}
impl Default for LevelState {
fn default() -> Self {
Self {
explosions_left: 1,
settle_timer: Timer::from_seconds(1.25, TimerMode::Once),
status: LevelStatus::Playing,
}
}
}
#[derive(Component)]
pub struct GameEntity;

11
src/events.rs Normal file
View File

@@ -0,0 +1,11 @@
use bevy::prelude::*;
#[derive(Event)]
pub struct ExplosionRequest {
pub pos: Vec2,
}
#[derive(Message)]
pub struct CircleDestroyedMessage {
pub pos: Vec2,
}

3
src/lib.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod components;
pub mod events;
pub mod plugins;

6
src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
use bevy::prelude::*;
use chain_reaction::plugins::game::GamePlugin;
fn main() {
App::new().add_plugins((DefaultPlugins, GamePlugin)).run();
}

79
src/plugins/background.rs Normal file
View File

@@ -0,0 +1,79 @@
use bevy::{
prelude::*,
render::render_resource::AsBindGroup,
shader::ShaderRef,
sprite_render::{Material2d, Material2dPlugin},
window::{PrimaryWindow, WindowResized},
};
pub struct BackgroundPlugin;
impl Plugin for BackgroundPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(Material2dPlugin::<BackgroundMaterial>::default())
.add_systems(Startup, setup_background)
.add_systems(Update, update_background_size);
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct BackgroundMaterial {
#[uniform(0)]
resolution: Vec2,
#[uniform(0)]
time_offset: f32,
}
impl Material2d for BackgroundMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/background.wgsl".into()
}
}
#[derive(Component)]
struct BackgroundMesh;
fn setup_background(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<BackgroundMaterial>>,
window_q: Single<&Window, With<PrimaryWindow>>,
) {
let window = *window_q;
// Create a quad that fills the screen
// We place it at Z = -100.0 so it is BEHIND everything
commands.spawn((
Mesh2d(meshes.add(Rectangle::default())), // Unit square, we will scale it
MeshMaterial2d(materials.add(BackgroundMaterial {
resolution: Vec2::new(window.width(), window.height()),
time_offset: 0.0,
})),
Transform::from_xyz(0.0, 0.0, -100.0).with_scale(Vec3::new(
window.width(),
window.height(),
1.0,
)),
BackgroundMesh,
));
}
// Keep the background filling the screen if window resizes
fn update_background_size(
mut events: MessageReader<WindowResized>,
mut mesh_q: Query<(&mut Transform, &MeshMaterial2d<BackgroundMaterial>), With<BackgroundMesh>>,
mut materials: ResMut<Assets<BackgroundMaterial>>,
) {
for event in events.read() {
for (mut transform, mat_handle) in mesh_q.iter_mut() {
// 1. Resize Mesh
transform.scale.x = event.width;
transform.scale.y = event.height;
// 2. Update Shader Resolution Uniform
if let Some(material) = materials.get_mut(mat_handle) {
material.resolution = Vec2::new(event.width, event.height);
}
}
}
}

94
src/plugins/game.rs Normal file
View File

@@ -0,0 +1,94 @@
use bevy::{prelude::*, window::PrimaryWindow};
use rand::Rng;
use crate::{
components::{
Circle as CircleComponent, GameConfig, GameEntity, GameState, LevelState, LevelStatus,
MainCamera, Velocity,
},
plugins::{
input::GameInputPlugin, movement::MovementPlugin, reaction::ReactionPlugin,
score::ScorePlugin, ui::UiPlugin,
},
};
pub struct GamePlugin;
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GameConfig>()
.init_resource::<LevelState>()
.insert_state(GameState::Menu)
.add_plugins((
GameInputPlugin,
MovementPlugin,
ScorePlugin,
ReactionPlugin,
UiPlugin,
))
.add_systems(Startup, setup)
.add_systems(
OnEnter(GameState::Playing),
(spawn_circles, reset_level_state),
)
.add_systems(OnExit(GameState::GameOver), cleanup_system)
.add_systems(OnExit(GameState::Menu), cleanup_system)
.add_systems(OnExit(GameState::Playing), cleanup_system);
}
}
fn setup(mut commands: Commands) {
commands.spawn((Camera2d::default(), MainCamera));
}
fn spawn_circles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window_q: Single<&Window, With<PrimaryWindow>>,
config: Res<GameConfig>,
) {
let window = *window_q;
let mut rng = rand::rng();
let count = rng.random_range(config.circle_count_min..=config.circle_count_max);
let base_circle_mesh = meshes.add(bevy::prelude::Circle::new(1.0));
for _ in 0..count {
let radius = rng.random_range(config.circle_radius_min..=config.circle_radius_max);
let max_x = window.width() / 2.0 - radius;
let max_y = window.height() / 2.0 - radius;
let x = rng.random_range(-max_x..=max_x);
let y = rng.random_range(-max_y..=max_y);
let hue = rng.random_range(0.0..360.0);
let color = Color::hsla(hue, 0.7, 0.8, rng.random_range(0.3..=1.0));
let velocity = Vec2::new(
rng.random_range(-config.max_velocity..=config.max_velocity),
rng.random_range(-config.max_velocity..=config.max_velocity),
);
commands.spawn((
CircleComponent { radius },
Velocity(velocity),
Mesh2d(base_circle_mesh.clone()),
MeshMaterial2d(materials.add(ColorMaterial::from(color))),
Transform::from_xyz(x, y, 0.0).with_scale(Vec3::splat(radius)),
GameEntity,
));
}
}
fn reset_level_state(mut level_state: ResMut<LevelState>, config: Res<GameConfig>) {
level_state.explosions_left = config.manual_explosion_limit;
level_state.settle_timer.reset();
level_state.status = LevelStatus::Playing;
}
fn cleanup_system(mut commands: Commands, query: Query<Entity, With<GameEntity>>) {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}

66
src/plugins/input.rs Normal file
View File

@@ -0,0 +1,66 @@
use bevy::{prelude::*, window::PrimaryWindow};
use crate::{
components::{GameState, MainCamera},
events::ExplosionRequest,
};
pub struct GameInputPlugin;
impl Plugin for GameInputPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
PostUpdate,
(mouse_input, touch_input).run_if(in_state(GameState::Playing)),
);
}
}
fn mouse_input(
mut commands: Commands,
mouse: Res<ButtonInput<MouseButton>>,
q_window: Single<&Window, With<PrimaryWindow>>,
q_camera: Single<(&Camera, &GlobalTransform), With<MainCamera>>,
) {
let (camera, camera_transform) = *q_camera;
let window = *q_window;
if mouse.just_pressed(MouseButton::Left) {
if let Some(world_position) = window
.cursor_position()
.and_then(|cursor| Some(camera.viewport_to_world(camera_transform, cursor)))
.map(|ray_result| {
let ray = ray_result.unwrap();
ray.origin.truncate()
})
{
commands.trigger(ExplosionRequest {
pos: world_position,
});
}
}
}
fn touch_input(
mut commands: Commands,
touches: Res<Touches>,
q_camera: Single<(&Camera, &GlobalTransform), With<MainCamera>>,
) {
let (camera, camera_transform) = *q_camera;
for touch in touches.iter_just_pressed() {
let position = touch.position();
let world_position = camera
.viewport_to_world(camera_transform, position)
.map(|ray| ray.origin.truncate());
match world_position {
Ok(pos) => {
commands.trigger(ExplosionRequest { pos });
}
Err(_) => {}
}
}
}

7
src/plugins/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod background;
pub mod game;
pub mod input;
pub mod movement;
pub mod reaction;
pub mod score;
pub mod ui;

56
src/plugins/movement.rs Normal file
View File

@@ -0,0 +1,56 @@
use bevy::{prelude::*, window::PrimaryWindow};
use crate::components::{Circle, GameState, Velocity};
pub struct MovementPlugin;
impl Plugin for MovementPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(apply_velocity, screen_bounce).run_if(in_state(GameState::Playing)),
);
}
}
fn apply_velocity(mut query: Query<(&mut Transform, &Velocity), With<Circle>>, time: Res<Time>) {
let delta_seconds = time.delta_secs();
for (mut transform, velocity) in query.iter_mut() {
transform.translation.x += velocity.0.x * delta_seconds;
transform.translation.y += velocity.0.y * delta_seconds;
}
}
fn screen_bounce(
mut query: Query<(&mut Transform, &mut Velocity, &Circle), With<Circle>>,
window_q: Single<&Window, With<PrimaryWindow>>,
) {
let window = *window_q;
let half_width = window.width() / 2.0;
let half_height = window.height() / 2.0;
for (mut transform, mut velocity, circle) in query.iter_mut() {
let radius = circle.radius;
let left_bound = -half_width + radius;
let right_bound = half_width - radius;
let bottom_bound = -half_height + radius;
let top_bound = half_height - radius;
if transform.translation.x < left_bound {
transform.translation.x = left_bound;
velocity.0.x = velocity.0.x.abs();
} else if transform.translation.x > right_bound {
transform.translation.x = right_bound;
velocity.0.x = -velocity.0.x.abs();
}
if transform.translation.y < bottom_bound {
transform.translation.y = bottom_bound;
velocity.0.y = velocity.0.y.abs();
} else if transform.translation.y > top_bound {
transform.translation.y = top_bound;
velocity.0.y = -velocity.0.y.abs();
}
}
}

195
src/plugins/reaction.rs Normal file
View File

@@ -0,0 +1,195 @@
use bevy::prelude::*;
use crate::{
components::{
ChainReaction, Circle as _Circle, Explosion, GameConfig, GameEntity, GameState, LevelState,
LevelStatus, SpatialGrid,
},
events::{CircleDestroyedMessage, ExplosionRequest},
};
pub struct ReactionPlugin;
impl Plugin for ReactionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SpatialGrid>()
.add_message::<CircleDestroyedMessage>()
.add_observer(on_explosion_request)
.add_systems(Startup, setup_grid)
.add_systems(Update, despawn_explosions)
.add_systems(
Update,
(
update_grid,
collision_detection,
process_chain_reactions,
check_level_completion,
)
.run_if(in_state(GameState::Playing)),
);
}
}
fn setup_grid(mut grid: ResMut<SpatialGrid>, config: Res<GameConfig>) {
grid.cell_size = config.cell_size;
}
fn update_grid(mut grid: ResMut<SpatialGrid>, query: Query<(Entity, &Transform), With<_Circle>>) {
grid.clear();
for (entity, transform) in query.iter() {
let pos = transform.translation.truncate();
let cell = grid.pos_to_cell(pos);
grid.cells.entry(cell).or_default().push(entity);
}
}
fn collision_detection(
mut commands: Commands,
grid: Res<SpatialGrid>,
config: Res<GameConfig>,
explosions: Query<(Entity, &GlobalTransform, &Explosion)>,
mut circles: Query<(Entity, &GlobalTransform, &_Circle)>,
mut messages: MessageWriter<CircleDestroyedMessage>,
) {
for (_exp_entity, exp_transform, explosion) in explosions.iter() {
let exp_pos = exp_transform.translation().truncate();
let center_cell = grid.pos_to_cell(exp_pos);
for x in -1..=1 {
for y in -1..=1 {
let neighbor_cell = center_cell + IVec2::new(x, y);
if let Some(entities) = grid.cells.get(&neighbor_cell) {
for &circle_entity in entities {
if let Ok((entity, circle_transform, circle_data)) =
circles.get_mut(circle_entity)
{
let circle_pos = circle_transform.translation().truncate();
let distance = exp_pos.distance(circle_pos);
if distance <= (explosion.radius + circle_data.radius) {
messages.write(CircleDestroyedMessage { pos: circle_pos });
commands
.entity(entity)
.remove::<_Circle>()
.insert(ChainReaction {
delay: Timer::from_seconds(
config.chain_reaction_delay,
TimerMode::Once,
),
next_radius: explosion.radius
* config.secondary_explosion_factor,
});
}
}
}
}
}
}
}
}
fn process_chain_reactions(
mut commands: Commands,
time: Res<Time>,
config: Res<GameConfig>,
mut query: Query<(Entity, &mut ChainReaction, &GlobalTransform)>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
for (entity, mut reaction, transform) in query.iter_mut() {
reaction.delay.tick(time.delta());
if reaction.delay.is_finished() {
let radius = reaction.next_radius;
commands.spawn((
Explosion {
timer: Timer::from_seconds(config.explosion_duration, TimerMode::Once),
radius,
},
Transform::from_translation(transform.translation()),
GlobalTransform::default(),
Mesh2d(meshes.add(Circle::new(radius))),
MeshMaterial2d(materials.add(Color::hsla(0.0, 1.0, 0.6, 0.8))),
GameEntity,
));
commands.entity(entity).despawn();
}
}
}
fn on_explosion_request(
trigger: On<ExplosionRequest>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
config: Res<GameConfig>,
mut level_state: ResMut<LevelState>,
) {
if level_state.explosions_left == 0 {
return;
}
level_state.explosions_left -= 1;
let pos = trigger.event().pos;
let radius = config.explosion_radius;
commands.spawn((
Explosion {
timer: Timer::from_seconds(config.explosion_duration, TimerMode::Once),
radius,
},
Transform::from_xyz(pos.x, pos.y, 1.0),
GlobalTransform::default(),
Mesh2d(meshes.add(Circle::new(radius))),
MeshMaterial2d(materials.add(Color::hsla(60.0, 1.0, 0.8, 0.8))),
GameEntity,
));
}
fn despawn_explosions(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Explosion)>,
) {
for (entity, mut explosion) in query.iter_mut() {
explosion.timer.tick(time.delta());
if explosion.timer.is_finished() {
commands.entity(entity).despawn();
}
}
}
fn check_level_completion(
time: Res<Time>,
mut level_state: ResMut<LevelState>,
mut next_state: ResMut<NextState<GameState>>,
active_explosions: Query<Entity, Or<(With<Explosion>, With<ChainReaction>)>>,
circles: Query<&_Circle>,
) {
let circle_count = circles.iter().count();
if circle_count == 0 {
level_state.status = LevelStatus::Victory;
next_state.set(GameState::GameOver);
return;
}
if level_state.explosions_left == 0 {
if active_explosions.is_empty() {
level_state.settle_timer.tick(time.delta());
if level_state.settle_timer.is_finished() {
level_state.status = LevelStatus::Defeat(circle_count);
next_state.set(GameState::GameOver);
}
} else {
level_state.settle_timer.reset();
}
}
}

85
src/plugins/score.rs Normal file
View File

@@ -0,0 +1,85 @@
use bevy::prelude::*;
use crate::{
components::{GameConfig, GameEntity, GameState, Score},
events::CircleDestroyedMessage,
};
pub struct ScorePlugin;
#[derive(Component)]
struct ScoreText;
impl Plugin for ScorePlugin {
fn build(&self, app: &mut App) {
app.insert_resource(Score {
total: 0,
multiplier: 1,
combo_timer: Timer::from_seconds(0.5, TimerMode::Once),
})
.add_systems(OnEnter(GameState::Playing), setup_ui)
.add_systems(Update, update_ui.run_if(in_state(GameState::Playing)))
.add_systems(
Update,
(award_points, manage_combo).run_if(in_state(GameState::Playing)),
);
}
}
fn award_points(
mut messages: MessageReader<CircleDestroyedMessage>,
mut score: ResMut<Score>,
config: Res<GameConfig>,
) {
let mut hits_this_frame = 0;
for _msg in messages.read() {
hits_this_frame += 1;
score.total += config.base_score * score.multiplier;
}
if hits_this_frame > 0 {
if score.multiplier < 32 {
score.multiplier += 1;
}
score
.combo_timer
.set_duration(std::time::Duration::from_secs_f32(config.combo_window));
score.combo_timer.reset();
}
}
fn manage_combo(time: Res<Time>, mut score: ResMut<Score>) {
if score.multiplier > 1 {
score.combo_timer.tick(time.delta());
if score.combo_timer.is_finished() {
score.multiplier = 1;
}
}
}
fn setup_ui(mut commands: Commands) {
commands.spawn((
Text::new("Score: 0 | x1"),
TextFont {
font_size: 30.0,
..default()
},
Node {
position_type: PositionType::Absolute,
top: Val::Px(10.0),
left: Val::Px(10.0),
..default()
},
ScoreText,
GameEntity,
));
}
fn update_ui(score: Res<Score>, mut query: Query<&mut Text, With<ScoreText>>) {
if score.is_changed() {
for mut text in query.iter_mut() {
*text = Text::new(format!("Score: {} | x{}", score.total, score.multiplier));
}
}
}

448
src/plugins/ui.rs Normal file
View File

@@ -0,0 +1,448 @@
use bevy::prelude::*;
use crate::components::{GameConfig, GameEntity, GameState, LevelState, LevelStatus, Score};
pub struct UiPlugin;
#[derive(Component)]
struct PlayAgainButton;
#[derive(Component)]
struct MenuButton;
#[derive(Component, Clone, Copy, PartialEq)]
enum ConfigTarget {
CircleCount,
ExplosionRadius,
GameSpeed,
ManualClicks,
}
// Logic component for the +/- buttons
#[derive(Component)]
struct ConfigButton {
target: ConfigTarget,
amount: f32, // e.g. +1.0 or -1.0
}
// Marker for the text that displays the value
#[derive(Component)]
struct ConfigValueText(ConfigTarget);
impl Plugin for UiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::Menu), setup_menu)
.add_systems(Update, menu_action.run_if(in_state(GameState::Menu)))
.add_systems(
Update,
config_button_handler.run_if(in_state(GameState::Menu)),
)
.add_systems(
Update,
update_config_display.run_if(in_state(GameState::Menu)),
)
.add_systems(OnEnter(GameState::GameOver), setup_game_over)
.add_systems(
Update,
game_over_action.run_if(in_state(GameState::GameOver)),
);
}
}
fn setup_menu(mut commands: Commands, config: Res<GameConfig>) {
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.1, 0.1, 0.1)),
GameEntity,
))
.with_children(|parent| {
// Title
parent.spawn((
Text::new("CHAIN REACTION"),
TextFont {
font_size: 60.0,
..default()
},
TextColor(Color::WHITE),
Node {
margin: UiRect::bottom(Val::Px(40.0)),
..default()
},
));
// --- CONFIGURATION PANEL ---
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
margin: UiRect::bottom(Val::Px(40.0)),
..default()
})
.with_children(|panel| {
// Row 1: Circle Count
spawn_config_row(
panel,
"Particles",
ConfigTarget::CircleCount,
config.circle_count_min as f32,
10.0,
);
// Row 2: Explosion Radius
spawn_config_row(
panel,
"Boom Size",
ConfigTarget::ExplosionRadius,
config.explosion_radius,
10.0,
);
// Row 3: Speed
spawn_config_row(
panel,
"Speed",
ConfigTarget::GameSpeed,
config.max_velocity,
25.0,
);
spawn_config_row(
panel,
"Shots",
ConfigTarget::ManualClicks,
config.manual_explosion_limit as f32,
1.0,
);
});
// Start Button
parent
.spawn((
Button,
Node {
width: Val::Px(200.0),
height: Val::Px(60.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.2, 0.6, 0.2)),
))
.with_children(|btn| {
btn.spawn((
Text::new("START GAME"),
TextFont {
font_size: 30.0,
..default()
},
TextColor(Color::WHITE),
));
});
});
}
fn menu_action(
interaction_q: Query<
(&Interaction, Option<&ConfigButton>),
(Changed<Interaction>, With<Button>),
>,
mut next_state: ResMut<NextState<GameState>>,
) {
for (interaction, config_btn) in interaction_q.iter() {
if *interaction == Interaction::Pressed && config_btn.is_none() {
next_state.set(GameState::Playing);
}
}
}
fn setup_game_over(mut commands: Commands, score: Res<Score>, level: Res<LevelState>) {
let final_score = score.total;
let (title_text, title_color, sub_text) = match level.status {
LevelStatus::Victory => (
"CLEARED!",
Color::srgb(0.2, 0.9, 0.2),
"Perfect Run!".to_string(),
),
LevelStatus::Defeat(count) => (
"OUT OF AMMO",
Color::srgb(0.9, 0.2, 0.2),
format!("Remaining: {}", count),
),
_ => ("GAME OVER", Color::WHITE, "".to_string()),
};
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
// Darker background for better contrast
BackgroundColor(Color::srgba(0.05, 0.05, 0.05, 0.9)),
GameEntity,
))
.with_children(|parent| {
// Main Title
parent.spawn((
Text::new(title_text),
TextFont {
font_size: 80.0,
..default()
},
TextColor(title_color),
));
// Subtitle (Remaining Count or "Perfect")
parent.spawn((
Text::new(sub_text),
TextFont {
font_size: 30.0,
..default()
},
TextColor(Color::srgb(0.8, 0.8, 0.8)),
Node {
margin: UiRect::top(Val::Px(10.0)),
..default()
},
));
// Score Display
parent.spawn((
Text::new(format!("Final Score: {}", final_score)),
TextFont {
font_size: 50.0,
..default()
},
TextColor(Color::srgb(1.0, 0.9, 0.2)), // Gold
Node {
margin: UiRect::all(Val::Px(30.0)),
..default()
},
));
// --- BUTTONS (Keep existing button logic) ---
// Restart Button
parent
.spawn((
Button,
Node {
width: Val::Px(250.0),
height: Val::Px(80.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
margin: UiRect::bottom(Val::Px(15.0)),
..default()
},
BackgroundColor(Color::srgb(0.2, 0.6, 0.2)),
PlayAgainButton,
))
.with_children(|parent| {
parent.spawn((
Text::new("PLAY AGAIN"),
TextFont {
font_size: 35.0,
..default()
},
TextColor(Color::WHITE),
));
});
// Menu Button
parent
.spawn((
Button,
Node {
width: Val::Px(250.0),
height: Val::Px(60.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.4, 0.4, 0.4)),
MenuButton,
))
.with_children(|parent| {
parent.spawn((
Text::new("MAIN MENU"),
TextFont {
font_size: 25.0,
..default()
},
TextColor(Color::WHITE),
));
});
});
}
fn game_over_action(
interaction_q: Query<
(&Interaction, Option<&PlayAgainButton>, Option<&MenuButton>),
(Changed<Interaction>, With<Button>),
>,
mut next_state: ResMut<NextState<GameState>>,
) {
for (interaction, play_again, menu) in interaction_q.iter() {
if *interaction == Interaction::Pressed {
if play_again.is_some() {
next_state.set(GameState::Playing);
} else if menu.is_some() {
next_state.set(GameState::Menu);
}
}
}
}
fn config_button_handler(
mut interaction_q: Query<(&Interaction, &ConfigButton), (Changed<Interaction>, With<Button>)>,
mut config: ResMut<GameConfig>,
) {
for (interaction, btn) in interaction_q.iter_mut() {
if *interaction == Interaction::Pressed {
match btn.target {
ConfigTarget::CircleCount => {
let new_min =
(config.circle_count_min as f32 + btn.amount).clamp(1.0, 500000.0) as u32;
config.circle_count_min = new_min;
config.circle_count_max = new_min + 10;
}
ConfigTarget::ExplosionRadius => {
config.explosion_radius =
(config.explosion_radius + btn.amount).clamp(10.0, 300.0);
}
ConfigTarget::GameSpeed => {
config.max_velocity = (config.max_velocity + btn.amount).clamp(0.0, 500.0);
}
ConfigTarget::ManualClicks => {
config.manual_explosion_limit =
(config.manual_explosion_limit as f32 + btn.amount).clamp(1.0, 10.0) as u32;
}
}
}
}
}
fn update_config_display(
config: Res<GameConfig>,
mut text_q: Query<(&mut Text, &ConfigValueText)>,
) {
if config.is_changed() {
for (mut text, tag) in text_q.iter_mut() {
let val = match tag.0 {
ConfigTarget::CircleCount => config.circle_count_min as f32,
ConfigTarget::ExplosionRadius => config.explosion_radius,
ConfigTarget::GameSpeed => config.max_velocity,
ConfigTarget::ManualClicks => config.manual_explosion_limit as f32,
};
*text = Text::new(format!("{:.0}", val));
}
}
}
fn spawn_config_row(
parent: &mut ChildSpawnerCommands,
label: &str,
target: ConfigTarget,
current_value: f32,
step: f32,
) {
parent
.spawn(Node {
width: Val::Px(400.0),
height: Val::Px(50.0),
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
margin: UiRect::bottom(Val::Px(10.0)),
..default()
})
.with_children(|row| {
// Label
row.spawn((
Text::new(label),
TextFont {
font_size: 20.0,
..default()
},
TextColor(Color::srgb(0.8, 0.8, 0.8)),
Node {
width: Val::Px(120.0),
..default()
},
));
// Decrease Button (-)
row.spawn((
Button,
Node {
width: Val::Px(40.0),
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
ConfigButton {
target,
amount: -step,
},
BackgroundColor(Color::srgb(0.3, 0.3, 0.3)),
))
.with_children(|btn| {
btn.spawn((
Text::new("-"),
TextFont {
font_size: 30.0,
..default()
},
TextColor(Color::WHITE),
));
});
// Value Display
row.spawn((
Text::new(format!("{:.0}", current_value)),
TextFont {
font_size: 25.0,
..default()
},
TextColor(Color::srgb(1.0, 0.8, 0.2)), // Gold color for values
ConfigValueText(target), // Tag for updating
));
// Increase Button (+)
row.spawn((
Button,
Node {
width: Val::Px(40.0),
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
ConfigButton {
target,
amount: step,
},
BackgroundColor(Color::srgb(0.3, 0.3, 0.3)),
))
.with_children(|btn| {
btn.spawn((
Text::new("+"),
TextFont {
font_size: 30.0,
..default()
},
TextColor(Color::WHITE),
));
});
});
}