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