Skip to main content

particles/
particles.rs

1use std::f32::consts::TAU;
2use jengine::engine::{Color, Game, jEngine, KeyCode};
3use jengine::ui::{Padding, BorderStyle};
4use jengine::ui::widgets::Widget;
5use jengine::ecs::{Entity, World};
6use jengine::{DEFAULT_TILESET, DEFAULT_FONT_METADATA, DEFAULT_TILE_W, DEFAULT_TILE_H};
7
8// ── Components ───────────────────────────────────────────────────────────────
9
10struct Position { x: f32, y: f32 }
11struct Velocity { vx: f32, vy: f32 }
12struct Life { current: f32, max: f32 }
13struct Particle {
14    color_start: [f32; 4],
15    color_end: [f32; 4],
16    size_start: f32,
17    size_end: f32,
18    drag: f32,
19}
20
21// ── Helper ───────────────────────────────────────────────────────────────────
22
23fn pseudo_rand(seed: u64) -> f32 {
24    let x = seed
25        .wrapping_mul(6364136223846793005)
26        .wrapping_add(1442695040888963407);
27    (x >> 33) as f32 / u32::MAX as f32
28}
29
30// ── Demo State ───────────────────────────────────────────────────────────────
31
32struct PostProcessState {
33    scanlines: bool,
34    vignette: bool,
35    chromatic: bool,
36    bloom: bool,
37}
38
39struct ParticleDemo {
40    world: World,
41    font_loaded: bool,
42    tick: u64,
43    pp: PostProcessState,
44}
45
46impl ParticleDemo {
47    fn new() -> Self {
48        Self {
49            world: World::new(),
50            font_loaded: false,
51            tick: 0,
52            pp: PostProcessState {
53                scanlines: false,
54                vignette: false,
55                chromatic: false,
56                bloom: false,
57            },
58        }
59    }
60
61    fn spawn_particle(&mut self, x: f32, y: f32, vx: f32, vy: f32, life: f32, 
62                      color_start: [f32; 4], color_end: [f32; 4], 
63                      size_start: f32, size_end: f32, drag: f32) {
64        let e = self.world.spawn();
65        self.world.insert(e, Position { x, y });
66        self.world.insert(e, Velocity { vx, vy });
67        self.world.insert(e, Life { current: life, max: life });
68        self.world.insert(e, Particle { color_start, color_end, size_start, size_end, drag });
69    }
70
71    // 1. Fire with Smoke
72    fn spawn_fire_and_smoke(&mut self, x: f32, y: f32) {
73        // Fire particles: fast, orange/yellow, short life
74        for i in 0..2 {
75            let seed = self.tick.wrapping_add(i as u64 * 137);
76            let vx = (pseudo_rand(seed) - 0.5) * 60.0;
77            let vy = -100.0 - pseudo_rand(seed.wrapping_add(1)) * 50.0;
78            let life = 0.2 + pseudo_rand(seed.wrapping_add(2)) * 0.3;
79            
80            let color_start = [1.0, 0.9, 0.2, 1.0]; // Bright yellow
81            let color_end = [1.0, 0.2, 0.0, 0.0];   // Fades to red/transparent
82            
83            self.spawn_particle(x, y, vx, vy, life, color_start, color_end, 8.0, 2.0, 1.5);
84        }
85
86        // Smoke: slow, grey, long life, rises higher
87        if self.tick % 4 == 0 {
88            let seed = self.tick.wrapping_add(999);
89            let vx = (pseudo_rand(seed) - 0.5) * 40.0;
90            let vy = -60.0 - pseudo_rand(seed.wrapping_add(1)) * 30.0;
91            let life = 1.5 + pseudo_rand(seed.wrapping_add(2)) * 1.0;
92            
93            let g = 0.4 + pseudo_rand(seed.wrapping_add(3)) * 0.2;
94            let color_start = [g, g, g, 0.5];
95            let color_end = [g * 0.5, g * 0.5, g * 0.5, 0.0];
96            
97            self.spawn_particle(x, y - 10.0, vx, vy, life, color_start, color_end, 4.0, 12.0, 0.8);
98        }
99    }
100
101    // 2. Explosion: grandiose multi-layered burst
102    fn spawn_explosion(&mut self, engine: &mut jEngine, x: f32, y: f32) {
103        // Trigger a camera shake for impact
104        engine.camera_shake(15.0);
105
106        // --- Layer A: The Core Flash (Bright, fast, very short) ---
107        for i in 0..30 {
108            let seed = self.tick.wrapping_add(i as u64 * 71);
109            let angle = pseudo_rand(seed) * TAU;
110            let speed = 200.0 + pseudo_rand(seed.wrapping_add(1)) * 400.0;
111            let vx = angle.cos() * speed;
112            let vy = angle.sin() * speed;
113            let life = 0.1 + pseudo_rand(seed.wrapping_add(2)) * 0.15;
114            self.spawn_particle(x, y, vx, vy, life, [1.0, 1.0, 1.0, 1.0], [1.0, 0.9, 0.5, 0.0], 12.0, 4.0, 5.0);
115        }
116
117        // --- Layer B: High-velocity Sparks (Fast, leave trails) ---
118        for i in 0..80 {
119            let seed = self.tick.wrapping_add(i as u64 * 113 + 500);
120            let angle = pseudo_rand(seed) * TAU;
121            let speed = 100.0 + pseudo_rand(seed.wrapping_add(1)) * 600.0;
122            let vx = angle.cos() * speed;
123            let vy = angle.sin() * speed;
124            let life = 0.3 + pseudo_rand(seed.wrapping_add(2)) * 0.5;
125            self.spawn_particle(x, y, vx, vy, life, [1.0, 0.6, 0.1, 1.0], [0.8, 0.1, 0.0, 0.0], 4.0, 1.0, 3.0);
126        }
127
128        // --- Layer C: Hot Embers / Smoke (Slow, lingering, rise slightly) ---
129        for i in 0..40 {
130            let seed = self.tick.wrapping_add(i as u64 * 197 + 1000);
131            let angle = pseudo_rand(seed) * TAU;
132            let speed = 20.0 + pseudo_rand(seed.wrapping_add(1)) * 80.0;
133            let vx = angle.cos() * speed;
134            let vy = angle.sin() * speed - 20.0; // Bias upward
135            let life = 0.8 + pseudo_rand(seed.wrapping_add(2)) * 1.2;
136            let grey = 0.3 + pseudo_rand(seed.wrapping_add(3)) * 0.2;
137            self.spawn_particle(x, y, vx, vy, life, [1.0, 0.3, 0.0, 0.8], [grey, grey, grey, 0.0], 6.0, 16.0, 1.0);
138        }
139    }
140
141    // 3. Glitch: horizontal jittery streaks
142    fn spawn_glitch(&mut self, x: f32, y: f32) {
143        let count = 12;
144        for i in 0..count {
145            let seed = self.tick.wrapping_add(i as u64 * 31);
146            let vx = (pseudo_rand(seed) - 0.5) * 600.0;
147            let vy = (pseudo_rand(seed.wrapping_add(1)) - 0.5) * 10.0;
148            let life = 0.05 + pseudo_rand(seed.wrapping_add(2)) * 0.15;
149            
150            let r = pseudo_rand(seed.wrapping_add(3));
151            let color = if r < 0.33 {
152                [0.0, 1.0, 1.0, 1.0] // Cyan
153            } else if r < 0.66 {
154                [1.0, 0.0, 1.0, 1.0] // Magenta
155            } else {
156                [1.0, 1.0, 1.0, 1.0] // White
157            };
158            
159            // Glitch particles are wide but thin
160            self.spawn_particle(x, y + (pseudo_rand(seed.wrapping_add(4)) - 0.5) * 40.0, 
161                               vx, vy, life, color, color, 12.0, 2.0, 0.0);
162        }
163    }
164
165    // 4. Slash: arc of light
166    fn spawn_slash(&mut self, x: f32, y: f32) {
167        let count = 20;
168        let base_angle = pseudo_rand(self.tick) * TAU;
169        for i in 0..count {
170            let seed = self.tick.wrapping_add(i as u64 * 17);
171            let angle = base_angle + (i as f32 / count as f32 - 0.5) * 0.8;
172            let speed = 300.0 + pseudo_rand(seed) * 150.0;
173            
174            let vx = angle.cos() * speed;
175            let vy = angle.sin() * speed;
176            let life = 0.1 + pseudo_rand(seed.wrapping_add(1)) * 0.1;
177            
178            let color_start = [0.8, 0.9, 1.0, 1.0]; // Pale blue
179            let color_end = [1.0, 1.0, 1.0, 0.0];
180            
181            self.spawn_particle(x, y, vx, vy, life, color_start, color_end, 2.0, 6.0, 8.0);
182        }
183    }
184}
185
186impl Game for ParticleDemo {
187    fn on_enter(&mut self, engine: &mut jEngine) {
188        let sw = engine.renderer.window.inner_size().width as f32;
189        let sh = engine.renderer.window.inner_size().height as f32;
190        // Center camera so world coordinates match screen coordinates (0,0 is top-left)
191        engine.set_camera_pos(sw * 0.5, sh * 0.5);
192    }
193
194    fn update(&mut self, engine: &mut jEngine) {
195        self.tick += 1;
196        let dt = engine.dt();
197
198        if engine.is_key_pressed(KeyCode::Escape) {
199            engine.request_quit();
200        }
201
202        // --- Continuous Fire at bottom ---
203        let sw = engine.renderer.window.inner_size().width as f32;
204        let sh = engine.renderer.window.inner_size().height as f32;
205        self.spawn_fire_and_smoke(sw * 0.5, sh * 0.85);
206
207        // --- Triggered effects ---
208        if engine.input.is_mouse_pressed(jengine::input::MouseButton::Left) && !engine.input.mouse_consumed {
209            let [mx, my] = engine.input.mouse_pos;
210            self.spawn_explosion(engine, mx, my);
211        }
212
213        if engine.is_key_pressed(KeyCode::KeyG) {
214            let [mx, my] = engine.input.mouse_pos;
215            self.spawn_glitch(mx, my);
216        }
217
218        if engine.is_key_pressed(KeyCode::KeyS) {
219            let [mx, my] = engine.input.mouse_pos;
220            self.spawn_slash(mx, my);
221        }
222
223        // --- Post-processing Toggles ---
224        let mut pp_changed = false;
225        if engine.is_key_pressed(KeyCode::Digit1) { self.pp.scanlines = !self.pp.scanlines; pp_changed = true; }
226        if engine.is_key_pressed(KeyCode::Digit2) { self.pp.vignette = !self.pp.vignette; pp_changed = true; }
227        if engine.is_key_pressed(KeyCode::Digit3) { self.pp.chromatic = !self.pp.chromatic; pp_changed = true; }
228        if engine.is_key_pressed(KeyCode::Digit4) { self.pp.bloom = !self.pp.bloom; pp_changed = true; }
229
230        if pp_changed {
231            engine.renderer.post_process.clear_effects();
232            if self.pp.scanlines { engine.set_scanlines(true); }
233            if self.pp.vignette { engine.set_vignette(true); }
234            if self.pp.chromatic { engine.set_chromatic_aberration(true); }
235            if self.pp.bloom { engine.set_bloom(true); }
236        }
237
238        // --- Movement & Lifetime System ---
239        let dead: Vec<Entity> = self.world.query_multi_mut::<(Position, Velocity, Life, Particle)>()
240            .filter_map(|(e, (pos, vel, life, p))| {
241                life.current -= dt;
242                if life.current <= 0.0 {
243                    return Some(e);
244                }
245
246                // Drag: v = v * (1 - drag * dt)
247                let drag_factor = (1.0 - p.drag * dt).max(0.0);
248                vel.vx *= drag_factor;
249                vel.vy *= drag_factor;
250
251                pos.x += vel.vx * dt;
252                pos.y += vel.vy * dt;
253
254                None
255            })
256            .collect();
257
258        for e in dead {
259            self.world.despawn(e);
260        }
261    }
262
263    fn render(&mut self, engine: &mut jEngine) {
264        if !self.font_loaded {
265            if let Ok(font) = jengine::renderer::text::Font::from_mtsdf_json(DEFAULT_FONT_METADATA) {
266                engine.renderer.set_mtsdf_distance_range(font.distance_range);
267                engine.ui.text.set_font(font);
268            }
269            self.font_loaded = true;
270        }
271
272        engine.clear();
273
274        let sw = engine.renderer.window.inner_size().width as f32;
275        let sh = engine.renderer.window.inner_size().height as f32;
276
277        // Set dark navy background using the tile grid (Pass 1)
278        // This ensures particles (Pass 3) are drawn ON TOP of the background.
279        for y in 0..engine.grid_height() {
280            for x in 0..engine.grid_width() {
281                engine.set_background(x, y, Color([0.01, 0.01, 0.02, 1.0]));
282            }
283        }
284
285        // --- Render System ---
286        for (_e, (pos, life, p)) in self.world.query_multi::<(Position, Life, Particle)>() {
287            let t = (life.current / life.max).clamp(0.0, 1.0);
288            
289            // Lerp color
290            let mut c = [0.0; 4];
291            for i in 0..4 {
292                c[i] = p.color_end[i] + (p.color_start[i] - p.color_end[i]) * t;
293            }
294            
295            // Lerp size
296            let size = p.size_end + (p.size_start - p.size_end) * t;
297            
298            engine.draw_particle(pos.x, pos.y, Color(c), size);
299        }
300
301        // --- UI ---
302        let tw = engine.tile_width() as f32;
303        let th = engine.tile_height() as f32;
304        
305        engine.ui.ui_text(tw, th, "PARTICLE SHOWCASE", Color::WHITE, Color::TRANSPARENT, Some(48.0));
306        
307        let mut y = th * 4.0;
308        let help = [
309            "MOUSE LCLICK : Spawn Explosion",
310            "[S] KEY      : Spawn Slash (at mouse)",
311            "[G] KEY      : Spawn Glitch (at mouse)",
312            "CONTINUOUS   : Fire & Smoke (bottom)",
313            "[ESC]        : Quit demo"
314        ];
315        
316        for line in help {
317            engine.ui.ui_text(tw, y, line, Color([0.6, 0.7, 0.7, 1.0]), Color::TRANSPARENT, None);
318            y += th * 1.2;
319        }
320
321        y += th;
322        let pp_help = [
323            format!("[1] Scanlines: {}", if self.pp.scanlines { "ON" } else { "OFF" }),
324            format!("[2] Vignette:  {}", if self.pp.vignette  { "ON" } else { "OFF" }),
325            format!("[3] Chromatic: {}", if self.pp.chromatic { "ON" } else { "OFF" }),
326            format!("[4] Bloom:     {}", if self.pp.bloom     { "ON" } else { "OFF" }),
327        ];
328        for line in pp_help {
329            engine.ui.ui_text(tw, y, &line, Color([0.4, 0.9, 0.6, 1.0]), Color::TRANSPARENT, None);
330            y += th * 1.2;
331        }
332        
333        let count = self.world.entity_count();
334        engine.ui.ui_text(sw - tw * 18.0, sh - th * 2.5, &format!("Active Entities: {}", count), Color::CYAN, Color::TRANSPARENT, Some(20.0));
335    }
336
337    fn debug_render(&mut self, engine: &mut jEngine) -> Option<Box<dyn jengine::ui::widgets::Widget>> {
338        use jengine::ui::widgets::{VStack, TextWidget};
339        use jengine::ui::Alignment;
340
341        let fs = 12.0;
342        let tw = engine.tile_width() as f32;
343        let th = engine.tile_height() as f32;
344
345        // ── 1. World-Space Highlight & Standalone Hover Popup ──
346        let [mx, my] = engine.input.mouse_pos;
347        let [wx, wy] = engine.screen_to_world(mx, my);
348        let gx = (wx / tw).floor();
349        let gy = (wy / th).floor();
350        
351        // Highlight tile
352        let [s_x, s_y] = engine.world_to_screen(gx * tw, gy * th);
353        let z = engine.camera_zoom();
354        engine.ui.debug_box(s_x, s_y, tw * z, th * z, Color::CYAN);
355
356        // List entities at hovered tile (pos is in pixel space; gx/gy are tile indices).
357        let mut hovered_entities = Vec::new();
358        for (entity, pos) in self.world.query::<Position>() {
359            if pos.x >= gx * tw && pos.x < (gx + 1.0) * tw
360                && pos.y >= gy * th && pos.y < (gy + 1.0) * th {
361                let components = self.world.components_for_entity(entity);
362                let short_names: Vec<String> = components.iter()
363                    .map(|&full_name| full_name.split("::").last().unwrap_or(full_name).to_string())
364                    .collect();
365                hovered_entities.push((entity, short_names));
366            }
367        }
368
369        // Draw standalone popup next to cursor using a styled VStack
370        if !hovered_entities.is_empty() {
371            let h_x = mx + 15.0;
372            let h_y = my + 15.0;
373            let panel_w = 220.0;
374            
375            let mut popup = VStack::new(Alignment::Start)
376                .with_padding(Padding::all(5.0))
377                .with_bg(Color([0.05, 0.05, 0.1, 0.8]))
378                .with_border(BorderStyle::Thin, Color::CYAN);
379            
380            for (entity, comps) in hovered_entities {
381                popup = popup.add(TextWidget {
382                    text: format!("E{}: {}", entity.id(), comps.join(", ")),
383                    size: Some(fs),
384                    color: Some(Color::WHITE),
385                });
386            }
387            Widget::draw(&mut popup, engine, h_x, h_y, panel_w, None);
388        }
389
390        // ── 2. Build Draggable Content ──
391        let total_entities = self.world.entity_count();
392        let mut stack = VStack::new(Alignment::Start).with_spacing(2.0);
393
394        stack = stack.add(TextWidget {
395            text: format!("Entities (Total): {}", total_entities),
396            size: Some(fs),
397            color: Some(Color::DARK_GRAY),
398        });
399
400        stack = stack.add(TextWidget {
401            text: "--- ENTITY LIST ---".to_string(),
402            size: Some(fs),
403            color: Some(Color::CYAN),
404        });
405
406        // Fetch first 100 entities to ensure we have enough content to scroll
407        let entities = self.world.entities_debug_info_paginated(0, 100);
408        for (entity, components) in entities {
409            let mut short_comps = Vec::new();
410            for c in components {
411                short_comps.push(c.split("::").last().unwrap_or(c));
412            }
413            stack = stack.add(TextWidget {
414                text: format!("E{}: {}", entity.id(), short_comps.join(", ")),
415                size: Some(fs),
416                color: Some(Color::BLACK),
417            });
418        }
419
420        if total_entities > 10 {
421            stack = stack.add(TextWidget {
422                text: "... and more".to_string(),
423                size: Some(fs),
424                color: Some(Color([0.4, 0.4, 0.4, 1.0])),
425            });
426        }
427
428        Some(Box::new(stack))
429    }
430}
431
432fn main() {
433    jEngine::builder()
434        .with_title("jengine — Particle Showcase")
435        .with_size(1280, 720)
436        .with_tileset(DEFAULT_TILESET, DEFAULT_TILE_W, DEFAULT_TILE_H)
437        .run(ParticleDemo::new());
438}