demo_gl/
demo.rs

1#![allow(clippy::uninlined_format_args)]
2//! A demo RPG character sheet application.  This file contains the common code including
3//! ui layout and logic.  `demo_glium.rs` and `demo_wgpu.rs` both use this file.
4//! This file contains example uses of many of Thyme's features.
5
6use std::path::Path;
7use std::collections::HashMap;
8use thyme::{Context, ContextBuilder, Frame, bench, ShowElement, Renderer};
9
10pub fn register_assets(context_builder: &mut ContextBuilder) {
11    // register resources in thyme by reading from files.  this enables live reload.
12    context_builder.register_theme_from_files(
13        &[
14            Path::new("examples/data/themes/base.yml"),
15            Path::new("examples/data/themes/demo.yml"),
16            // note we dynamically add/remove from this list later if the user selects a new theme
17            Path::new("examples/data/themes/pixel.yml"),
18        ],
19    ).unwrap();
20    context_builder.register_texture_from_file("pixel", Path::new("examples/data/images/pixel.png"));
21    context_builder.register_texture_from_file("fantasy", Path::new("examples/data/images/fantasy.png"));
22    context_builder.register_texture_from_file("transparent", Path::new("examples/data/images/transparent.png"));
23    context_builder.register_texture_from_file("golden", Path::new("examples/data/images/golden.png"));
24    context_builder.register_font_from_file("Roboto-Medium", Path::new("examples/data/fonts/Roboto-Medium.ttf"));
25    context_builder.register_font_from_file("Roboto-Italic", Path::new("examples/data/fonts/Roboto-Italic.ttf"));
26    context_builder.register_font_from_file("Roboto-Bold", Path::new("examples/data/fonts/Roboto-Bold.ttf"));
27    context_builder.register_font_from_file("Roboto-BoldItalic", Path::new("examples/data/fonts/Roboto-BoldItalic.ttf"));
28}
29
30#[derive(Debug, Copy, Clone, Default)]
31enum ThemeChoice {
32    #[default]
33    Pixels,
34    Fantasy,
35    Transparent,
36    Golden,
37    NoImage,
38}
39
40const THEME_CHOICES: [ThemeChoice; 5] = [
41    ThemeChoice::Pixels,
42    ThemeChoice::Fantasy,
43    ThemeChoice::Transparent,
44    ThemeChoice::Golden,
45    ThemeChoice::NoImage
46];
47
48impl ThemeChoice {
49    fn path(self) -> &'static str {
50        match self {
51            ThemeChoice::Fantasy => "examples/data/themes/fantasy.yml",
52            ThemeChoice::Pixels => "examples/data/themes/pixel.yml",
53            ThemeChoice::Transparent => "examples/data/themes/transparent.yml",
54            ThemeChoice::Golden => "examples/data/themes/golden.yml",
55            ThemeChoice::NoImage => "examples/data/themes/no_image.yml",
56        }
57    }
58}
59
60impl std::fmt::Display for ThemeChoice {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(f, "{:?}", self)
63    }
64}
65
66#[derive(Default)]
67pub struct Party {
68    members: Vec<Character>,
69    editing_index: Option<usize>,
70
71    live_reload_disabled: bool,
72    reload_assets: bool,
73    old_theme_choice: Option<ThemeChoice>,
74    theme_choice: ThemeChoice,
75}
76
77impl Party {
78    pub fn theme_has_mouse_cursor(&self) -> bool {
79        match self.theme_choice {
80            ThemeChoice::Pixels | ThemeChoice::Fantasy | ThemeChoice::Transparent | ThemeChoice::Golden => true,
81            ThemeChoice::NoImage => false,
82        }
83    }
84
85    pub fn check_context_changes<R: Renderer>(&mut self, context: &mut Context, renderer: &mut R) {
86        if let Some(old_choice) = self.old_theme_choice.take() {
87            context.remove_theme_file(old_choice.path());
88            context.add_theme_file(self.theme_choice.path());
89        }
90
91        if self.reload_assets {
92            if let Err(e) = context.rebuild_all(renderer) {
93                log::error!("Unable to rebuild theme: {}", e);
94            }
95            self.reload_assets = false;
96        } else if !self.live_reload_disabled {
97            if let Err(e) = context.check_live_reload(renderer) {
98                log::error!("Unable to live reload theme: {}", e);
99            }
100        }
101    }
102}
103
104const MIN_AGE: f32 = 18.0;
105const DEFAULT_AGE: f32 = 25.0;
106const MAX_AGE: f32 = 50.0;
107const INITIAL_GP: u32 = 100;
108const MIN_STAT: u32 = 3;
109const MAX_STAT: u32 = 18;
110const STAT_POINTS: u32 = 75;
111
112struct Character {
113    name: String,
114    age: f32,
115    stats: HashMap<Stat, u32>,
116
117    race: Race,
118    gp: u32,
119    items: Vec<Item>,
120}
121
122impl Character {
123    fn generate(index: usize) -> Character {
124        Character {
125            name: format!("Charname {}", index),
126            age: DEFAULT_AGE,
127            stats: HashMap::default(),
128            gp: INITIAL_GP,
129            items: Vec::default(),
130            race: Race::default(),
131        }
132    }
133}
134
135#[derive(Debug, Copy, Clone, Default)]
136enum Race {
137    #[default]
138    Human,
139    Elf,
140    Dwarf,
141    Halfling,
142}
143
144impl Race {
145    fn all() -> &'static [Race] {
146        use Race::*;
147        &[Human, Elf, Dwarf, Halfling]
148    }
149}
150
151impl std::fmt::Display for Race {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write!(f, "{:?}", self)
154    }
155}
156
157#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
158enum Stat {
159    Strength,
160    Dexterity,
161    Constitution,
162    Intelligence,
163    Wisdom,
164    Charisma,
165}
166
167impl Stat {
168    fn iter() -> impl Iterator<Item=Stat> + 'static {
169        use Stat::*;
170        [Strength, Dexterity, Constitution, Intelligence, Wisdom, Charisma].iter().copied()
171    }
172}
173
174#[derive(Clone)]
175struct Item {
176    name: &'static str,
177    price: u32,
178}
179
180const ITEMS: [Item; 3] = [
181    Item { name: "Sword", price: 50 },
182    Item { name: "Shield", price: 20 },
183    Item { name: "Torch", price: 2 }
184];
185
186/// The function to build the Thyme user interface.  Called once each frame.  This
187/// example demonstrates a combination of Rust layout and styling as well as use
188/// of the theme definition file, loaded above
189pub fn build_ui(ui: &mut Frame, party: &mut Party) {
190    match party.theme_choice {
191        ThemeChoice::Pixels | ThemeChoice::Fantasy | ThemeChoice::Transparent | ThemeChoice::Golden => {
192            // show a custom cursor.  it automatically inherits mouse presses in its state
193            ui.set_mouse_cursor("gui/cursor", thyme::Align::TopLeft);
194        },
195        ThemeChoice::NoImage => {
196            // don't show a custom cursor
197        }
198    }
199
200    ui.label("bench", format!(
201        "{}\n{}\n{}",
202        bench::short_report("thyme"),
203        bench::short_report("frame"),
204        bench::short_report("draw"),
205    ));
206
207    ui.start("theme_panel").children(|ui| {
208        if ui.start("live_reload").active(!party.live_reload_disabled).finish().clicked {
209            party.live_reload_disabled = !party.live_reload_disabled;
210        }
211
212        if let Some(choice) = ui.combo_box("theme_choice", "theme_choice", &party.theme_choice, &THEME_CHOICES) {
213            party.old_theme_choice = Some(party.theme_choice);
214            party.theme_choice = *choice;
215            party.reload_assets = true;
216        }
217    });
218
219    ui.start("party_window")
220    .window("party_window")
221    .with_close_button(false)
222    .moveable(false)
223    .resizable(false)
224    .children(|ui| {
225        ui.scrollpane("members_panel", "party_content", |ui| {
226            party_members_panel(ui, party);
227        });
228    });
229
230    if let Some(index) = party.editing_index {
231        let character = &mut party.members[index];
232
233        ui.window("character_window", |ui| {
234            ui.scrollpane("pane", "character_content", |ui| {
235                ui.start("name_panel")
236                .children(|ui| {
237                    if let Some(new_name) = ui.input_field("name_input", "name_input", None) {
238                        character.name = new_name;
239                    }
240                });
241
242                ui.gap(10.0);
243                ui.label("age_label", format!("Age: {}", character.age.round() as u32));
244                if let Some(age) = ui.horizontal_slider("age_slider", MIN_AGE, MAX_AGE, character.age) {
245                    character.age = age;
246                }
247
248                for stat in Stat::iter() {
249                    let value = format!("{}", character.stats.get(&stat).unwrap_or(&10));
250                    let key = format!("{:?}", stat);
251                    ui.set_variable(key, value);
252                }
253
254                ui.scrollpane("description_panel", "description_pane", |ui| {
255                    ui.text_area("description_box");
256                });
257
258                ui.gap(10.0);
259
260                if let Some(race) = ui.combo_box("race_selector", "race_selector", &character.race, Race::all()) {
261                    character.race = *race;
262                }
263    
264                ui.gap(10.0);
265    
266                ui.tree("stats_panel", "stats_panel", true,
267                |ui| {
268                    ui.child("title");
269                },|ui| {
270                    stats_panel(ui, character);
271                });
272                
273                ui.gap(10.0);
274    
275                ui.tree("inventory_panel", "inventory_panel", true,
276                |ui| {
277                    ui.child("title");
278                }, |ui| {
279                    inventory_panel(ui, character);
280                });
281            });
282        });
283
284        ui.window("item_picker", |ui| {
285            let display_size = ui.display_size();
286
287            ui.start("greyed_out")
288            .unclip()
289            .unparent()
290            .size(display_size.x, display_size.y)
291            .screen_pos(0.0, 0.0).finish();
292
293            item_picker(ui, character);
294        });
295    }
296}
297
298fn party_members_panel(ui: &mut Frame, party: &mut Party) {
299    for (index, member) in party.members.iter_mut().enumerate() {
300        let clicked = ui.start("filled_slot_button")
301        .text(&member.name)
302        .active(Some(index) == party.editing_index)
303        .finish().clicked;
304
305        if clicked {
306            set_active_character(ui, member);
307            party.editing_index = Some(index);
308        }
309    }
310
311    if ui.start("add_character_button").finish().clicked {
312        let new_member = Character::generate(party.members.len());
313        set_active_character(ui, &new_member);
314        party.members.push(new_member);
315        party.editing_index = Some(party.members.len() - 1);
316    }
317}
318
319fn set_active_character(ui: &mut Frame, character: &Character) {
320    ui.open("character_window");
321    ui.modify("name_input", |state| {
322        state.text = Some(character.name.clone());
323    });
324    ui.close("item_picker");
325}
326
327fn stats_panel(ui: &mut Frame, character: &mut Character) {
328    let points_used: u32 = character.stats.values().sum();
329    let points_available: u32 = STAT_POINTS - points_used;
330    let frac = ((ui.cur_time_millis() - ui.base_time_millis("stat_roll")) as f32 / 1000.0).min(1.0);
331
332    let roll = ui.start("roll_button")
333    .enabled(frac > 0.99)
334    .children(|ui| {
335        ui.progress_bar("progress_bar", frac);
336    });
337
338    if roll.clicked {
339        ui.set_base_time_now("stat_roll");
340    }
341
342    for stat in Stat::iter() {
343        let value = character.stats.entry(stat).or_insert(10);
344
345        ui.tree(
346        "stat_panel",
347        &format!("stat_panel_{:?}", stat),
348        false,
349        |ui| {
350            ui.label("label", format!("{:?}", stat));
351
352            match ui.spinner("spinner", *value, MIN_STAT, if points_available == 0 { *value } else { MAX_STAT }) {
353                1 => *value += 1,
354                -1 => *value -= 1,
355                _ => (),
356            }
357        }, |ui| {
358            ui.child("description");
359        });
360    }
361
362    ui.label("points_available", format!("Points Remaining: {}", points_available));
363}
364
365fn item_picker(ui: &mut Frame, character: &mut Character) {
366    for item in ITEMS.iter() {
367        let clicked = ui.start("item_button")
368        .enabled(character.gp >= item.price)
369        .children(|ui| {
370            ui.label("name", item.name);
371            // TODO icon image
372            ui.child("icon");
373            ui.label("price", format!("{} Gold", item.price));
374        }).clicked;
375
376        if clicked {
377            character.gp -= item.price;
378            character.items.push(item.clone());
379            ui.close("item_picker");
380        }
381    }
382}
383
384fn inventory_panel(ui: &mut Frame, character: &mut Character) {
385    ui.start("top_panel")
386    .children(|ui| {
387        if ui.child("buy").clicked {
388            ui.open_modal("item_picker");
389        }
390
391        ui.label("gold", format!("{} Gold", character.gp));
392    });
393    
394    ui.start("items_panel")
395    .scrollpane("items_content")
396    .show_vertical_scrollbar(ShowElement::Always)
397    .children(|ui| {
398        items_panel(ui, character);
399    });
400}
401
402fn items_panel(ui: &mut Frame, character: &mut Character) {
403    let mut sell = None;
404    for (index, item) in character.items.iter().enumerate() {
405        let result = ui.button("item_button", item.name);
406        if result.clicked {
407            sell = Some(index);
408        }
409        
410        if result.hovered {
411            // manually specify a tooltip
412            ui.tooltip_label("tooltip", "Remove Item");
413        }
414    }
415
416    if let Some(index) = sell {
417        let item = character.items.remove(index);
418        character.gp += item.price;
419    }
420}