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}