Skip to main content

ecs/
ecs.rs

1//! # ECS Example
2//!
3//! Demonstrates jengine's sparse-set Entity-Component System (ECS).
4//!
5//! Concepts shown:
6//!   · `World::spawn` / `World::despawn` — entity lifecycle with generational handles
7//!   · `World::insert` — attach any `'static` type as a component (no registration required)
8//!   · `World::get` / `World::get_mut` — fetch a single component by entity handle
9//!   · `World::query` — iterate all entities that have a given component (`&T`)
10//!   · `World::query_mut` — same, but yields `&mut T`
11//!   · `World::query_multi_mut` — iterate entities that have ALL listed components (`&mut T, &mut U, …`)
12//!   · Dead-entity safety — despawned handles are ignored by `get`/`has`
13//!
14//! Controls:
15//!   Space  — spawn a new entity at the grid centre
16//!   D      — deal 25 damage to every living entity; dead ones despawn next tick
17//!   Esc    — quit
18
19use jengine::ecs::World;
20use jengine::engine::{Color, Game, jEngine, KeyCode};
21use jengine::renderer::text::Font;
22use jengine::ui::modern::Panel;
23use jengine::{DEFAULT_FONT_METADATA, DEFAULT_TILE_H, DEFAULT_TILE_W, DEFAULT_TILESET};
24
25// ── Components ────────────────────────────────────────────────────────────────
26// Components are plain Rust structs — no derive macros or trait impls required.
27
28/// Tile-grid position (column, row).
29struct Position {
30    x: u32,
31    y: u32,
32}
33
34/// Movement direction in grid cells per move-step (can be negative).
35struct Velocity {
36    dx: i32,
37    dy: i32,
38}
39
40/// Hit-points. Entity is queued for removal when `current` drops to zero.
41struct Health {
42    current: i32,
43    max: i32,
44}
45
46/// Visual representation: which character and colour to draw.
47struct Renderable {
48    glyph: char,
49    color: Color,
50}
51
52// ── Game ──────────────────────────────────────────────────────────────────────
53
54struct EcsDemo {
55    /// The ECS world owns all entities and their component data.
56    world: World,
57    /// Incremented on each spawn to cycle through colours and glyphs.
58    spawn_seq: u64,
59    /// Entities advance one tile every `MOVE_INTERVAL` ticks.
60    move_timer: u64,
61    /// True once the bitmap font has been registered in the UI text layer.
62    font_loaded: bool,
63}
64
65/// How many fixed-update ticks between entity movement steps.
66const MOVE_INTERVAL: u64 = 18;
67
68impl EcsDemo {
69    fn new() -> Self {
70        let mut demo = Self {
71            world: World::new(),
72            spawn_seq: 0,
73            move_timer: 0,
74            font_loaded: false,
75        };
76        // Pre-populate the world so there is something to look at immediately.
77        for i in 0..8 {
78            demo.spawn_at(8 + i * 6, 7);
79        }
80        demo
81    }
82
83    /// Spawn one entity at grid position `(x, y)`.
84    ///
85    /// Velocity and appearance cycle deterministically with `spawn_seq` so that
86    /// each new entity looks different from the previous one.
87    fn spawn_at(&mut self, x: u32, y: u32) {
88        // `World::spawn` returns a generational Entity handle.  All subsequent
89        // component inserts reference this handle.
90        let entity = self.world.spawn();
91
92        let seq = self.spawn_seq;
93
94        // Diagonal direction — four quadrants cycling with seq.
95        let (dx, dy) = match seq % 4 {
96            0 => (1_i32, 1_i32),
97            1 => (-1, 1),
98            2 => (1, -1),
99            _ => (-1, -1),
100        };
101
102        // Colour palette cycles through six entries.
103        let color = [
104            Color::CYAN,
105            Color::YELLOW,
106            Color::GREEN,
107            Color::MAGENTA,
108            Color::ORANGE,
109            Color::WHITE,
110        ][seq as usize % 6];
111
112        // Glyph cycles through printable ASCII symbols.
113        let glyph = ['@', '&', '%', '*', '#', '+'][seq as usize % 6];
114
115        // `World::insert` accepts any `'static` value — no registration step.
116        self.world.insert(entity, Position { x, y });
117        self.world.insert(entity, Velocity { dx, dy });
118        self.world.insert(entity, Health { current: 100, max: 100 });
119        self.world.insert(entity, Renderable { glyph, color });
120
121        self.spawn_seq += 1;
122    }
123}
124
125impl Game for EcsDemo {
126    fn update(&mut self, engine: &mut jEngine) {
127        // ── Quit ──────────────────────────────────────────────────────────────
128        if engine.is_key_pressed(KeyCode::Escape) {
129            engine.request_quit();
130            return;
131        }
132
133        // ── Spawn a new entity ────────────────────────────────────────────────
134        if engine.is_key_pressed(KeyCode::Space) {
135            let cx = engine.grid_width() / 2;
136            let cy = engine.grid_height() / 2;
137            self.spawn_at(cx, cy);
138        }
139
140        // ── Deal damage to every entity ───────────────────────────────────────
141        // `query_mut` yields `(Entity, &mut Health)` — single-component mutable.
142        if engine.is_key_pressed(KeyCode::KeyD) {
143            for (_entity, health) in self.world.query_mut::<Health>() {
144                health.current -= 25;
145            }
146        }
147
148        // ── Move entities on a fixed interval ─────────────────────────────────
149        self.move_timer += 1;
150        if self.move_timer >= MOVE_INTERVAL {
151            self.move_timer = 0;
152            let gw = engine.grid_width() as i32;
153            let gh = engine.grid_height() as i32;
154
155            // `query_multi_mut` yields `(Entity, (&mut Position, &mut Velocity))`
156            // for every entity that has BOTH components.  The borrow checker
157            // guarantees no aliasing because all type parameters are distinct.
158            for (_entity, (pos, vel)) in self.world.query_multi_mut::<(Position, Velocity)>() {
159                // `rem_euclid` wraps negative results correctly (unlike `%`).
160                pos.x = (pos.x as i32 + vel.dx).rem_euclid(gw) as u32;
161                pos.y = (pos.y as i32 + vel.dy).rem_euclid(gh) as u32;
162            }
163        }
164
165        // ── Despawn dead entities ─────────────────────────────────────────────
166        // We collect the dead handles first so we don't mutate the world while
167        // the query iterator still holds a shared borrow.
168        let dead: Vec<_> = self
169            .world
170            .query::<Health>()
171            .filter(|(_e, h)| h.current <= 0)
172            .map(|(e, _)| e)
173            .collect();
174
175        for entity in dead {
176            // `despawn` increments the entity's generation, invalidating any
177            // copies of the old handle.  Components are removed automatically.
178            self.world.despawn(entity);
179        }
180    }
181
182    fn render(&mut self, engine: &mut jEngine) {
183        // Register the bitmap font once so that `ui_text` can render glyphs.
184        if !self.font_loaded {
185            if let Ok(font) = Font::from_mtsdf_json(DEFAULT_FONT_METADATA) {
186                engine.ui.text.set_font(font);
187            }
188            self.font_loaded = true;
189        }
190
191        engine.clear();
192
193        let gw = engine.grid_width();
194        let gh = engine.grid_height();
195
196        // Checkerboard background — makes individual tile positions easy to see.
197        for y in 0..gh {
198            for x in 0..gw {
199                let shade = if (x + y) % 2 == 0 {
200                    Color([0.06, 0.06, 0.07, 1.0])
201                } else {
202                    Color([0.04, 0.04, 0.05, 1.0])
203                };
204                engine.set_background(x, y, shade);
205            }
206        }
207
208        // Draw every entity that has a Position and a Renderable.
209        //
210        // `query_multi` yields `(Entity, (&Position, &Renderable))`.  We can
211        // also call `world.get::<Health>(entity)` inside the loop because both
212        // borrows are shared (`&`) and thus non-conflicting.
213        for (entity, (pos, rend)) in self.world.query_multi::<(Position, Renderable)>() {
214            // Dim the glyph proportionally to remaining health — pure data lookup.
215            let frac = self
216                .world
217                .get::<Health>(entity)
218                .map(|h| h.current as f32 / h.max as f32)
219                .unwrap_or(1.0)
220                .clamp(0.0, 1.0);
221
222            let dimmed = Color([
223                rend.color.0[0] * frac,
224                rend.color.0[1] * frac,
225                rend.color.0[2] * frac,
226                1.0,
227            ]);
228
229            // Solid black behind each entity so it stands out from the checker.
230            engine.set_background(pos.x, pos.y, Color::BLACK);
231            engine.set_foreground(pos.x, pos.y, rend.glyph, dimmed);
232        }
233
234        // ── UI overlay (always drawn on top of the world) ─────────────────────
235        let count = self.world.query::<Position>().count();
236        let sw = gw as f32 * engine.tile_width() as f32;
237
238        Panel::new(0.0, 0.0, sw, 30.0).with_color(Color([0.0, 0.0, 0.0, 0.85])).draw(engine);
239        engine.ui.ui_text(
240            20.0,
241            8.0,
242            &format!(
243                "Entities: {count:<3}  |  [Space] spawn  [D] damage all  [Esc] quit"
244            ),
245            Color::WHITE,
246            Color::TRANSPARENT, Some(14.0));
247    }
248}
249
250fn main() {
251    jEngine::builder()
252        .with_title("jengine — ECS")
253        .with_size(800, 576)
254        .with_tileset(DEFAULT_TILESET, DEFAULT_TILE_W, DEFAULT_TILE_H)
255        .run(EcsDemo::new());
256}