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:
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[target.wasm32-unknown-unknown]
|
||||
runner = "wasm-server-runner"
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
5504
Cargo.lock
generated
Normal file
5504
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
Cargo.toml
Normal file
37
Cargo.toml
Normal 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
86
GDD.md
Normal 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
21
LICENSE
Normal 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
113
README.md
Normal 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!
|
||||
|
||||

|
||||
|
||||
## 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!**
|
||||
169
assets/shaders/background.wgsl
Normal file
169
assets/shaders/background.wgsl
Normal 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
127
src/components.rs
Normal 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
11
src/events.rs
Normal 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
3
src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod components;
|
||||
pub mod events;
|
||||
pub mod plugins;
|
||||
6
src/main.rs
Normal file
6
src/main.rs
Normal 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
79
src/plugins/background.rs
Normal 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
94
src/plugins/game.rs
Normal 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
66
src/plugins/input.rs
Normal 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
7
src/plugins/mod.rs
Normal 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
56
src/plugins/movement.rs
Normal 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
195
src/plugins/reaction.rs
Normal 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
85
src/plugins/score.rs
Normal 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
448
src/plugins/ui.rs
Normal 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),
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user