game/devtools/
story.rs

1use serde::{Deserialize, Serialize};
2
3use geom::{Distance, LonLat, Pt2D, Ring};
4use map_gui::render::DrawOptions;
5use widgetry::mapspace::{ObjectID, World, WorldOutcome};
6use widgetry::tools::{ChooseSomething, Lasso, PromptInput};
7use widgetry::{
8    lctrl, Choice, Color, DrawBaselayer, EventCtx, GeomBatch, GfxCtx, HorizontalAlignment, Key,
9    Line, Outcome, Panel, SimpleState, State, Text, TextBox, VerticalAlignment, Widget,
10};
11
12use crate::app::{App, ShowEverything, Transition};
13
14// Good inspiration: http://sfo-assess.dha.io/, https://github.com/mapbox/storytelling,
15// https://storymap.knightlab.com/
16
17/// A simple tool to place markers and free-hand shapes over a map, then label them.
18pub struct StoryMapEditor {
19    panel: Panel,
20    story: StoryMap,
21    world: World<MarkerID>,
22
23    dirty: bool,
24}
25
26// TODO We'll constantly rebuild the world, so these are indices into a list of markers. Maybe we
27// should just assign opaque IDs and hash into them. (Deleting a marker in the middle of the list
28// would mean changing IDs of everything after it.)
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30struct MarkerID(usize);
31impl ObjectID for MarkerID {}
32
33impl StoryMapEditor {
34    pub fn new_state(ctx: &mut EventCtx) -> Box<dyn State<App>> {
35        Self::from_story(ctx, StoryMap::new())
36    }
37
38    fn from_story(ctx: &mut EventCtx, story: StoryMap) -> Box<dyn State<App>> {
39        let mut state = StoryMapEditor {
40            panel: Panel::empty(ctx),
41            story,
42            world: World::new(),
43
44            dirty: false,
45        };
46        state.rebuild_panel(ctx);
47        state.rebuild_world(ctx);
48        Box::new(state)
49    }
50
51    fn rebuild_panel(&mut self, ctx: &mut EventCtx) {
52        self.panel = Panel::new_builder(Widget::col(vec![
53            Widget::row(vec![
54                Line("Story map editor").small_heading().into_widget(ctx),
55                Widget::vert_separator(ctx, 30.0),
56                ctx.style()
57                    .btn_outline
58                    .popup(&self.story.name)
59                    .hotkey(lctrl(Key::L))
60                    .build_widget(ctx, "load"),
61                ctx.style()
62                    .btn_plain
63                    .icon("system/assets/tools/save.svg")
64                    .hotkey(lctrl(Key::S))
65                    .disabled(!self.dirty)
66                    .build_widget(ctx, "save"),
67                ctx.style().btn_close_widget(ctx),
68            ]),
69            ctx.style()
70                .btn_plain
71                .icon_text("system/assets/tools/select.svg", "Draw freehand")
72                .hotkey(Key::F)
73                .build_def(ctx),
74        ]))
75        .aligned(HorizontalAlignment::Center, VerticalAlignment::Top)
76        .build(ctx);
77    }
78
79    fn rebuild_world(&mut self, ctx: &mut EventCtx) {
80        let mut world = World::new();
81
82        for (idx, marker) in self.story.markers.iter().enumerate() {
83            let mut draw_normal = GeomBatch::new();
84            let label_center = if marker.pts.len() == 1 {
85                // TODO Erase the "B" from it though...
86                draw_normal = map_gui::tools::goal_marker(ctx, marker.pts[0], 2.0);
87                marker.pts[0]
88            } else {
89                let poly = Ring::must_new(marker.pts.clone()).into_polygon();
90                draw_normal.push(Color::RED.alpha(0.8), poly.clone());
91                draw_normal.push(Color::RED, poly.to_outline(Distance::meters(1.0)));
92                poly.polylabel()
93            };
94
95            let mut draw_hovered = draw_normal.clone();
96
97            draw_normal.append(
98                Text::from(&marker.label)
99                    .bg(Color::CYAN)
100                    .render_autocropped(ctx)
101                    .scale(0.5)
102                    .centered_on(label_center),
103            );
104            let hitbox = draw_normal.get_bounds().to_circle().to_polygon();
105            draw_hovered.append(
106                Text::from(&marker.label)
107                    .bg(Color::CYAN)
108                    .render_autocropped(ctx)
109                    .scale(0.75)
110                    .centered_on(label_center),
111            );
112
113            world
114                .add(MarkerID(idx))
115                .hitbox(hitbox)
116                .draw(draw_normal)
117                .draw_hovered(draw_hovered)
118                .hotkey(Key::Backspace, "delete")
119                .clickable()
120                .draggable()
121                .build(ctx);
122        }
123
124        world.initialize_hover(ctx);
125        world.rebuilt_during_drag(ctx, &self.world);
126        self.world = world;
127    }
128}
129
130impl State<App> for StoryMapEditor {
131    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
132        match self.world.event(ctx) {
133            WorldOutcome::ClickedFreeSpace(pt) => {
134                self.story.markers.push(Marker {
135                    pts: vec![pt],
136                    label: String::new(),
137                });
138                self.dirty = true;
139                self.rebuild_panel(ctx);
140                self.rebuild_world(ctx);
141                return Transition::Push(EditingMarker::new_state(
142                    ctx,
143                    self.story.markers.len() - 1,
144                    "new marker",
145                ));
146            }
147            WorldOutcome::Dragging {
148                obj: MarkerID(idx),
149                dx,
150                dy,
151                ..
152            } => {
153                for pt in &mut self.story.markers[idx].pts {
154                    *pt = pt.offset(dx, dy);
155                }
156                self.dirty = true;
157                self.rebuild_panel(ctx);
158                self.rebuild_world(ctx);
159            }
160            WorldOutcome::Keypress("delete", MarkerID(idx)) => {
161                self.story.markers.remove(idx);
162                self.dirty = true;
163                self.rebuild_panel(ctx);
164                self.rebuild_world(ctx);
165            }
166            WorldOutcome::ClickedObject(MarkerID(idx)) => {
167                return Transition::Push(EditingMarker::new_state(
168                    ctx,
169                    idx,
170                    &self.story.markers[idx].label,
171                ));
172            }
173            _ => {}
174        }
175
176        if let Outcome::Clicked(x) = self.panel.event(ctx) {
177            match x.as_ref() {
178                "close" => {
179                    // TODO autosave
180                    return Transition::Pop;
181                }
182                "save" => {
183                    if self.story.name == "new story" {
184                        return Transition::Push(PromptInput::new_state(
185                            ctx,
186                            "Name this story map",
187                            String::new(),
188                            Box::new(|name, _, _| {
189                                Transition::Multi(vec![
190                                    Transition::Pop,
191                                    Transition::ModifyState(Box::new(move |state, ctx, app| {
192                                        let editor =
193                                            state.downcast_mut::<StoryMapEditor>().unwrap();
194                                        editor.story.name = name;
195                                        editor.story.save(app);
196                                        editor.dirty = false;
197                                        editor.rebuild_panel(ctx);
198                                    })),
199                                ])
200                            }),
201                        ));
202                    } else {
203                        self.story.save(app);
204                        self.dirty = false;
205                        self.rebuild_panel(ctx);
206                    }
207                }
208                "load" => {
209                    // TODO autosave
210                    let mut choices = Vec::new();
211                    for (name, story) in
212                        abstio::load_all_objects::<RecordedStoryMap>(abstio::path_player("stories"))
213                    {
214                        if story.name == self.story.name {
215                            continue;
216                        }
217                        if let Some(s) = StoryMap::load(app, story) {
218                            choices.push(Choice::new(name, s));
219                        }
220                    }
221                    choices.push(Choice::new(
222                        "new story",
223                        StoryMap {
224                            name: "new story".to_string(),
225                            markers: Vec::new(),
226                        },
227                    ));
228
229                    return Transition::Push(ChooseSomething::new_state(
230                        ctx,
231                        "Load story",
232                        choices,
233                        Box::new(|story, ctx, _| {
234                            Transition::Multi(vec![
235                                Transition::Pop,
236                                Transition::Replace(StoryMapEditor::from_story(ctx, story)),
237                            ])
238                        }),
239                    ));
240                }
241                "Draw freehand" => {
242                    return Transition::Push(Box::new(DrawFreehand {
243                        lasso: Lasso::new(Distance::meters(1.0)),
244                        new_idx: self.story.markers.len(),
245                    }));
246                }
247                _ => unreachable!(),
248            }
249        }
250
251        Transition::Keep
252    }
253
254    fn draw_baselayer(&self) -> DrawBaselayer {
255        DrawBaselayer::Custom
256    }
257
258    fn draw(&self, g: &mut GfxCtx, app: &App) {
259        let mut opts = DrawOptions::new();
260        opts.label_buildings = true;
261        app.draw(g, opts, &ShowEverything::new());
262
263        self.panel.draw(g);
264        self.world.draw(g);
265    }
266}
267
268#[derive(Clone, Serialize, Deserialize)]
269struct RecordedStoryMap {
270    name: String,
271    markers: Vec<(Vec<LonLat>, String)>,
272}
273
274struct StoryMap {
275    name: String,
276    markers: Vec<Marker>,
277}
278
279struct Marker {
280    pts: Vec<Pt2D>,
281    label: String,
282}
283
284impl StoryMap {
285    fn new() -> StoryMap {
286        StoryMap {
287            name: "new story".to_string(),
288            markers: Vec::new(),
289        }
290    }
291
292    fn load(app: &App, story: RecordedStoryMap) -> Option<StoryMap> {
293        let mut markers = Vec::new();
294        for (gps_pts, label) in story.markers {
295            markers.push(Marker {
296                pts: app.primary.map.get_gps_bounds().try_convert(&gps_pts)?,
297                label,
298            });
299        }
300        Some(StoryMap {
301            name: story.name,
302            markers,
303        })
304    }
305
306    fn save(&self, app: &App) {
307        let story = RecordedStoryMap {
308            name: self.name.clone(),
309            markers: self
310                .markers
311                .iter()
312                .map(|m| {
313                    (
314                        app.primary.map.get_gps_bounds().convert_back(&m.pts),
315                        m.label.clone(),
316                    )
317                })
318                .collect(),
319        };
320        abstio::write_json(
321            abstio::path_player(format!("stories/{}.json", story.name)),
322            &story,
323        );
324    }
325}
326
327struct EditingMarker {
328    idx: usize,
329}
330
331impl EditingMarker {
332    fn new_state(ctx: &mut EventCtx, idx: usize, label: &str) -> Box<dyn State<App>> {
333        let panel = Panel::new_builder(Widget::col(vec![
334            Widget::row(vec![
335                Line("Editing marker").small_heading().into_widget(ctx),
336                ctx.style().btn_close_widget(ctx),
337            ]),
338            ctx.style().btn_outline.text("delete").build_def(ctx),
339            TextBox::default_widget(ctx, "label", label.to_string()),
340            ctx.style()
341                .btn_outline
342                .text("confirm")
343                .hotkey(Key::Enter)
344                .build_def(ctx),
345        ]))
346        .build(ctx);
347        <dyn SimpleState<_>>::new_state(panel, Box::new(EditingMarker { idx }))
348    }
349}
350
351impl SimpleState<App> for EditingMarker {
352    fn on_click(
353        &mut self,
354        _: &mut EventCtx,
355        _: &mut App,
356        x: &str,
357        panel: &mut Panel,
358    ) -> Transition {
359        match x {
360            "close" => Transition::Pop,
361            "confirm" => {
362                let idx = self.idx;
363                let label = panel.text_box("label");
364                Transition::Multi(vec![
365                    Transition::Pop,
366                    Transition::ModifyState(Box::new(move |state, ctx, _| {
367                        let editor = state.downcast_mut::<StoryMapEditor>().unwrap();
368                        editor.story.markers[idx].label = label;
369
370                        editor.dirty = true;
371                        editor.rebuild_panel(ctx);
372                        editor.rebuild_world(ctx);
373                    })),
374                ])
375            }
376            "delete" => {
377                let idx = self.idx;
378                Transition::Multi(vec![
379                    Transition::Pop,
380                    Transition::ModifyState(Box::new(move |state, ctx, _| {
381                        let editor = state.downcast_mut::<StoryMapEditor>().unwrap();
382                        editor.story.markers.remove(idx);
383
384                        editor.dirty = true;
385                        editor.rebuild_panel(ctx);
386                        editor.rebuild_world(ctx);
387                    })),
388                ])
389            }
390            _ => unreachable!(),
391        }
392    }
393
394    fn draw_baselayer(&self) -> DrawBaselayer {
395        DrawBaselayer::PreviousState
396    }
397}
398
399struct DrawFreehand {
400    lasso: Lasso,
401    new_idx: usize,
402}
403
404impl State<App> for DrawFreehand {
405    fn event(&mut self, ctx: &mut EventCtx, _: &mut App) -> Transition {
406        if let Some(polygon) = self.lasso.event(ctx) {
407            let idx = self.new_idx;
408            return Transition::Multi(vec![
409                Transition::Pop,
410                Transition::ModifyState(Box::new(move |state, ctx, _| {
411                    let editor = state.downcast_mut::<StoryMapEditor>().unwrap();
412                    editor.story.markers.push(Marker {
413                        pts: polygon.into_outer_ring().into_points(),
414                        label: String::new(),
415                    });
416
417                    editor.dirty = true;
418                    editor.rebuild_panel(ctx);
419                    editor.rebuild_world(ctx);
420                })),
421                Transition::Push(EditingMarker::new_state(ctx, idx, "new marker")),
422            ]);
423        }
424
425        Transition::Keep
426    }
427
428    fn draw_baselayer(&self) -> DrawBaselayer {
429        DrawBaselayer::PreviousState
430    }
431
432    fn draw(&self, g: &mut GfxCtx, _: &App) {
433        self.lasso.draw(g);
434    }
435}