MudEngine Part 5: Polished UI with dioxus-components
Clickable room cards
The D-PAD buttons let you click to move, but there is an even more intuitive way: click the room card itself. Instead of pressing "North", click the Hilltop card directly.
In this step we make the room cards clickable — but only the ones you can actually reach. Non-adjacent rooms stay inert. You click a card, and if there is a valid exit to that room, the player moves there.
What we change
RoomCell— gainsclickableandonclickprops; when clickable, clicking the cell callsgo(direction)on the worldWorldGrid— now takesSignal<World>(write access), computes which rooms are reachable from the current room by scanning theexitslist, and passes click handlers to each cell- CSS — clickable cells get a pointer cursor, border, and hover glow
Currently WorldGrid takes ReadSignal<World> — a read-only view. To call world.write().go(dir) when the player clicks a cell, we need write access. Changing the prop to Signal<World> gives us both .read() (for rendering) and .write() (for mutations).
In App, world is already a Signal<World> — it is passed directly, so no change needed at the call site. Only WorldGrid's function signature changes.
use dioxus::prelude::*; #[component] pub fn RoomCell( name: String, active: bool, clickable: bool, onclick: EventHandler<MouseEvent>, ) -> Element { rsx! { div { class: if active { "cell active" } else if clickable { "cell clickable" } else { "cell" }, style: if active { "background: #3b82f6; color: #fff; font-weight: bold;" } else if clickable { "background: #1a1a3e; color: #7a7aaa;" } else { "background: #12122a; color: #5a5a7a;" }, onclick: move |e| if clickable { onclick(e) }, "{name}" } } }
clickable: bool— whentrue, the cell gets a lighter background and the.cell.clickableCSS class. Theonclickhandler only fires whenclickableistrue.onclick: EventHandler<MouseEvent>— the callback that runs when the cell is clicked. For reachable rooms this will callworld.write().go(direction). For unreachable rooms it is a no-op.
The EventHandler<MouseEvent> type is Dioxus's standard event callback. It wraps a closure and implements Clone + PartialEq so it works as a component prop. You pass a closure directly when calling RoomCell — Dioxus converts it automatically.
Replace the entire WorldGrid component with this version. It changes the prop type from ReadSignal<World> to Signal<World>, and uses .map().collect() to build the grid cells outside the rsx! macro — each cell checks the current room's exits to determine if it is clickable:
use crate::components::room_cell::RoomCell; use crate::game::world::World; use dioxus::prelude::*; #[component] pub fn WorldGrid(world: Signal<World>) -> Element { let w = world.read(); let player_room = w.player_room; let names: Vec<String> = w.rooms.iter().map(|r| r.name.clone()).collect(); // Clone the current room's exits to iterate outside the read guard let exits = w.rooms[player_room].exits.clone(); drop(w); // Build each cell outside the rsx! macro — rsx! does not support let bindings in for loops let cells: Vec<Element> = names.iter().enumerate().map(|(idx, name)| { let is_active = idx == player_room; // Is there an exit from the current room pointing to this cell? let exit_dir = exits.iter() .find(|(_, target)| *target == idx) .map(|(dir, _)| dir.clone()); let is_clickable = exit_dir.is_some(); // Clone for the onclick closure let mut world2 = world.clone(); let dir2 = exit_dir.clone(); rsx! { RoomCell { key: "{idx}", name: name.clone(), active: is_active, clickable: is_clickable, onclick: move |_: MouseEvent| { if let Some(ref d) = dir2 { world2.write().go(*d); } }, } } }).collect(); rsx! { div { class: "world-grid", {cells.into_iter()} } } }
Each room stores its exits as Vec<(Direction, usize)> — a list of (direction, target_room_index) pairs. For example, Town Square (room 4) has:
exits: vec![
(Direction::North, 1), // Hilltop
(Direction::South, 7), // Old Bridge
(Direction::West, 3), // Dark Forest
(Direction::East, 5), // Temple Courtyard
]
In the .map() call over room indices, we check: does the current room have an exit whose target is this index? If yes, we know the direction and the room is reachable.
For Hilltop (index 1) when the player is in Town Square — exit (Direction::North, 1) matches → exit_dir = Some(Direction::North) → cell is clickable.
For Riverbank (index 6) when the player is in Town Square — no exit targets index 6 → exit_dir = None → cell is inert.
When the player clicks a reachable cell, the onclick closure fires world2.write().go(*d), which moves the player. Dioxus detects the signal change and re-renders the grid with the new current room and updated reachable rooms.
Open assets/main.css and add these rules at the end. They give clickable cells a pointer cursor, a visible border, and a hover glow effect:
/* ── Clickable cells ── */ .world-grid .cell.clickable { cursor: pointer; border: 1px solid #2a2a5a; transition: filter 0.2s, border-color 0.2s, box-shadow 0.2s; } .world-grid .cell.clickable:hover { filter: brightness(1.35); border-color: #3b82f6; box-shadow: 0 0 12px rgba(59, 130, 246, 0.15); } .world-grid .cell.clickable:active { filter: brightness(0.9); transform: scale(0.97); }
| Rule | Effect |
|---|---|
cursor: pointer | Changes the cursor to a hand icon on hover |
border: 1px solid #2a2a5a | Gives clickable cells a subtle border (non-clickable cells have no border) |
:hover | Brightens the cell, adds a blue border, and a soft glow |
:active | Darkens and slightly shrinks the cell on click for tactile feedback |
The filter: brightness(...) approach avoids fighting the inline background colour — it brightens whatever background is already set.
Run dx serve and open the app. Start in Town Square (room 4, the center):
- Click "Hilltop" (the card north of Town Square) → the player moves to Hilltop. The grid updates: Hilltop is now blue (active), and its adjacent rooms — Dark Forest (south) and Abandoned Tower (east) — become clickable.
- Click "Abandoned Tower" → the player moves again. The exits update to show Abandoned Tower's reachable rooms.
- Hover over a clickable cell → you see the pointer cursor and the brightening glow effect. Hover over a dimmed non-adjacent cell → nothing happens.
- Click a non-adjacent cell (like "Riverbank" when at Town Square) → nothing happens. The
exit_dirisNone, so theonclickhandler is a no-op.
The D-PAD buttons and the keyboard input still work — all three navigation methods coexist.