ascii_hangman_webapp/
lib.rs1#![cfg(target_arch = "wasm32")]
5#![recursion_limit = "512"]
6
7use ascii_hangman_backend::game::State;
8use ascii_hangman_backend::Backend;
9use ascii_hangman_backend::HangmanBackend;
10use ascii_hangman_backend::{AUTHOR, CONF_TEMPLATE, CONF_TEMPLATE_SHORT, TITLE, VERSION};
11use wasm_bindgen::prelude::*;
12use yew::events::KeyboardEvent;
13use yew::prelude::*;
14use yew::services::reader::{File, FileData, ReaderService, ReaderTask};
15use yew::services::DialogService;
18use yew::{html, Component, ComponentLink, Html, InputData, ShouldRender};
19
20#[derive(Debug)]
21pub enum Scene {
22 Playground(Backend),
23 ConfigureGame,
24 GameOver,
25}
26
27pub struct GuiState {
28 config_text: String,
29 guess: String,
30}
31
32pub struct Model {
33 link: ComponentLink<Self>,
34 filereader_tasks: Vec<ReaderTask>,
37 scene: Scene,
38 state: GuiState,
39}
40
41#[derive(Debug)]
42pub enum Msg {
43 SwitchTo(Scene),
44 ConfigTextDelete,
45 ConfigTextUpdate(String),
46 ConfigReady,
47 Files(Vec<File>),
48 Loaded(FileData),
49 UpdateGuess(String),
50 Guess,
51 Nope,
52 NextRound,
53}
54
55impl Component for Model {
56 type Message = Msg;
57 type Properties = ();
58
59 fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
60 let state = GuiState {
61 config_text: String::from(CONF_TEMPLATE_SHORT),
62 guess: String::new(),
63 };
64
65 Model {
66 link,
67 filereader_tasks: vec![],
70 scene: Scene::ConfigureGame,
71 state,
72 }
73 }
74
75 fn update(&mut self, msg: Self::Message) -> ShouldRender {
76 let mut new_scene = None;
77 match &mut self.scene {
78 Scene::Playground(ref mut app) => match msg {
79 Msg::SwitchTo(Scene::ConfigureGame) => {
80 new_scene = Some(Scene::ConfigureGame);
81 }
82 Msg::SwitchTo(Scene::GameOver) => {
83 new_scene = Some(Scene::GameOver);
84 }
85 Msg::UpdateGuess(val) => {
86 self.state.guess = val.chars().rev().take(1).collect();
87 }
90 Msg::Guess => {
91 app.process_user_input(&self.state.guess);
92 self.state.guess = String::new();
93 }
94 Msg::Nope => {}
95 unexpected => {
96 panic!(
97 "Unexpected message when configurations list shown: {:?}",
98 unexpected
99 );
100 }
101 },
102 Scene::ConfigureGame => match msg {
103 Msg::ConfigTextUpdate(val) => {
104 self.state.config_text = val;
105 }
106 Msg::ConfigTextDelete => {
107 self.state.config_text = CONF_TEMPLATE_SHORT.to_string();
108 }
109 Msg::SwitchTo(Scene::Playground(app)) => {
110 new_scene = Some(Scene::Playground(app));
111 }
112
113 Msg::SwitchTo(Scene::GameOver) => {
114 if DialogService::confirm("Do you really want to quit this game?") {
115 new_scene = Some(Scene::GameOver);
116 }
117 }
118
119 Msg::Loaded(file) => {
120 if let Ok(s) = std::str::from_utf8(&file.content) {
121 self.state.config_text.push_str(s);
122 } else {
123 DialogService::alert(&format!("Can not read text file: {}", file.name));
124 }
125 }
126 Msg::Files(files) => {
127 for file in files.into_iter() {
128 let task = {
129 let callback = self.link.callback(Msg::Loaded);
130 ReaderService::read_file(file, callback).unwrap()
131 };
132 self.filereader_tasks.push(task);
133 }
134 }
135 Msg::ConfigReady => {
136 match Backend::new(self.state.config_text.as_str()) {
137 Ok(app) => {
138 self.link
139 .send_message(Msg::SwitchTo(Scene::Playground(app)));
140 }
141 Err(e) => {
142 DialogService::alert(&format!("Can not parse configuration:\n {}", e))
143 }
144 };
145 }
146 unexpected => {
147 panic!(
148 "Unexpected message during new config editing: {:?}",
149 unexpected
150 );
151 }
152 },
153 Scene::GameOver => match msg {
154 Msg::SwitchTo(Scene::ConfigureGame) => {
155 new_scene = Some(Scene::ConfigureGame);
156 }
157 unexpected => {
158 panic!("Unexpected message for settings scene: {:?}", unexpected);
159 }
160 },
161 }
162 if let Some(new_scene) = new_scene.take() {
163 self.scene = new_scene;
164 }
165 true
166 }
167
168 fn change(&mut self, _: Self::Properties) -> ShouldRender {
169 false
170 }
171
172 fn view(&self) -> Html {
173 let header = move || -> Html {
174 html! { <h1> {TITLE} </h1> }
175 };
176 let footer = move || -> Html {
177 html! { <footer class="footer">
178 <a href="../ascii-hangman--manual.html"> {"User Manual"} </a>
179 {", "} <a href="../#distribution"> {"Desktop Version"} </a>
180 {", "} <a href="../"> {"Documentation"} </a>
181 {", "} <a href="https://github.com/getreu/ascii-hangman"> {"Source Code"} </a>
182 {", Version "} {VERSION.unwrap()} {", "} {AUTHOR} </footer> }
183 };
184 match self.scene {
185 Scene::ConfigureGame => html! { <>
186 {header()}
187 <div class="ascii-hangman-wasm">
188 <div> {"Enter your secrets here:"}</div>
189 <div>
190 <textarea class="conf-text"
191 placeholder=CONF_TEMPLATE
192 cols=80
193 rows=25
194 value=self.state.config_text.clone()
195 oninput=self.link.callback(|e: InputData| Msg::ConfigTextUpdate(e.value)) />
196 </div>
197 <div class="upload-container"> { "or load secrets from files: "}
198 <label class="upload-link" for="upload">{"Upload Files ..."}</label>
199 <input class="custom-file-input" type="file" id="upload" multiple=true onchange=self.link.callback(move |value| {
200 let mut result = Vec::new();
201 if let ChangeData::Files(files) = value {
202 let files = js_sys::try_iter(&files)
203 .unwrap()
204 .unwrap()
205 .into_iter()
206 .map(|v| File::from(v.unwrap()));
207 result.extend(files);
208 }
209 Msg::Files(result)
210 })/>
211 </div>
212
213 <button disabled=self.state.config_text.is_empty()
214 onclick=self.link.callback(|_| Msg::ConfigTextDelete)>{ "Delete Secrets" }</button>
215 <button disabled=self.state.config_text.is_empty()
216 onclick=self.link.callback(|_| Msg::ConfigReady)>{ "Start Game" }</button>
217 </div>
218 {footer()}
219 </>
220 },
221 Scene::Playground(ref app) => {
222 let secret = app.render_secret();
223 let (cols, rows) = dimensions(&secret);
224 let secret = secret.trim_end_matches("\n").to_string();
225 let image = app.render_image();
226 let image = image.trim_end_matches("\n").to_string();
227 html! { <>
228 {header()}
229 <div class="ascii-hangman-wasm">
230 <textarea class="image"
231 placeholder="Image"
232 cols=format!("{}", &app.get_image_dimension().0)
233 rows=format!("{}", &app.get_image_dimension().1)
234 value=image
235 readonly=true
236 />
237 <table class="game-status">
238 <tr>
239 <th>
240 { app.render_game_lifes() } { " " }
241 </th>
242 <th>
243 { app.render_game_last_guess() }
244 </th>
245 </tr>
246 </table>
247 <textarea class="secret"
248 cols=format!("{}", cols+1)
249 rows=format!("{}", rows)
250 value=secret
251 readonly=true
252 />
253 <div class="instructions">
254 { app.render_instructions() }
255 <input class="guess"
256 type="text"
257 autofocus=true
258 size=1
259 value=self.state.guess.clone()
260 oninput=self.link.callback(|e: InputData| Msg::UpdateGuess(e.value))
261 onkeypress=self.link.callback(|e: KeyboardEvent| {
262 if e.key() == "Enter" { Msg::Guess } else { Msg::Nope }
263 }) />
264
265 </div>
266 <button disabled={app.get_state() == State::Ongoing || app.get_state() == State::VictoryGameOver}
267 onclick=self.link.callback(|_| Msg::Guess)>{ "Continue Game" }</button>
268 <button disabled={app.get_state() != State::VictoryGameOver}
269 onclick=self.link.callback(|_| Msg::SwitchTo(Scene::ConfigureGame))>{ "Reset Game" }</button>
270 <button disabled={app.get_state() != State::VictoryGameOver}
271 onclick=self.link.callback(|_| Msg::SwitchTo(Scene::GameOver))>{ "End Game" }</button>
272 </div>
273 {footer()}
274 </>
275 }
276 }
277 Scene::GameOver => html! { <>
278 {header()}
279 <div> {"You can now close this window."}</div>
280 <div> <label>{ "Bye bye!" } </label>
281 <p/>
282 <button onclick=self.link.callback(|_| Msg::SwitchTo(Scene::ConfigureGame))>{ "No, Continue Playing" }</button>
283 </div>
284 {footer()}
285 </>
286 },
287 }
288 }
289}
290
291pub fn dimensions(s: &str) -> (usize, usize) {
294 let mut row = 0;
295 let mut col = 0;
296 for l in s.lines() {
297 let c = l.chars().count();
298 if c > col {
299 col = c;
300 };
301 row += 1;
302 }
303 (col, row)
304}
305
306#[wasm_bindgen(start)]
307pub fn run_app() {
308 App::<Model>::new().mount_to_body();
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use secret::Secret;
315 #[test]
316 fn test_dimensions() {
317 let secret = Secret::new("Lorem ipsum dolor sit amet, consectetur adipiscing\
318 elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\
319 quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure\
320 dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur\
321 sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est\
322 laborum.");
323
324 assert_eq!(dimensions(format!("{}", secret).as_str()), (68, 22));
327 }
329}