veloren_voxygen/hud/
minimap.rs

1use super::{
2    MapMarkers, QUALITY_COMMON, QUALITY_DEBUG, QUALITY_EPIC, QUALITY_HIGH, QUALITY_LOW,
3    QUALITY_MODERATE, TEXT_COLOR, UI_HIGHLIGHT_0, UI_MAIN,
4    img_ids::{Imgs, ImgsRot},
5};
6use crate::{
7    GlobalState,
8    hud::{Graphic, Ui},
9    session::settings_change::{Interface as InterfaceChange, Interface::*},
10    ui::{KeyedJobs, fonts::Fonts, img_ids},
11};
12use client::{self, Client};
13use common::{
14    comp,
15    comp::group::Role,
16    grid::Grid,
17    slowjob::SlowJobPool,
18    terrain::{
19        Block, BlockKind, CoordinateConversions, TerrainChunk, TerrainChunkSize, TerrainGrid,
20    },
21    vol::{ReadVol, RectVolSize},
22};
23use common_net::msg::world_msg::SiteKind;
24use common_state::TerrainChanges;
25use conrod_core::{
26    Color, Colorable, Positionable, Sizeable, Widget, WidgetCommon, color, position,
27    widget::{self, Button, Image, Rectangle, Text},
28    widget_ids,
29};
30use hashbrown::{HashMap, HashSet};
31use image::{DynamicImage, RgbaImage};
32use specs::WorldExt;
33use std::sync::Arc;
34use vek::*;
35
36struct MinimapColumn {
37    /// Coordinate of lowest z-slice
38    zlo: i32,
39    /// Z-slices of colors and filled-ness
40    layers: Vec<Grid<(Rgba<u8>, bool)>>,
41    /// Color and filledness above the highest layer
42    above: (Rgba<u8>, bool),
43    /// Color and filledness below the lowest layer
44    below: (Rgba<u8>, bool),
45}
46
47pub struct VoxelMinimap {
48    chunk_minimaps: HashMap<Vec2<i32>, MinimapColumn>,
49    chunks_to_replace: HashSet<Vec2<i32>>,
50    composited: RgbaImage,
51    image_id: img_ids::Rotations,
52    last_pos: Vec3<i32>,
53    last_ceiling: i32,
54    keyed_jobs: KeyedJobs<Vec2<i32>, MinimapColumn>,
55}
56
57const VOXEL_MINIMAP_SIDELENGTH: u32 = 256;
58
59impl VoxelMinimap {
60    pub fn new(ui: &mut Ui) -> Self {
61        let composited = RgbaImage::from_pixel(
62            VOXEL_MINIMAP_SIDELENGTH,
63            VOXEL_MINIMAP_SIDELENGTH,
64            image::Rgba([0, 0, 0, 64]),
65        );
66        Self {
67            chunk_minimaps: HashMap::new(),
68            chunks_to_replace: HashSet::new(),
69            image_id: ui.add_graphic_with_rotations(Graphic::Image(
70                Arc::new(DynamicImage::ImageRgba8(composited.clone())),
71                Some(Rgba::from([0.0, 0.0, 0.0, 0.0])),
72            )),
73            composited,
74            last_pos: Vec3::zero(),
75            last_ceiling: 0,
76            keyed_jobs: KeyedJobs::new("IMAGE_PROCESSING"),
77        }
78    }
79
80    fn block_color(block: &Block) -> Option<Rgba<u8>> {
81        block
82            .get_color()
83            .map(|rgb| Rgba::new(rgb.r, rgb.g, rgb.b, 255))
84            .or_else(|| {
85                matches!(block.kind(), BlockKind::Water).then(|| Rgba::new(119, 149, 197, 255))
86            })
87    }
88
89    /// Each layer is a slice of the terrain near that z-level
90    fn composite_layer_slice(chunk: &TerrainChunk, layers: &mut Vec<Grid<(Rgba<u8>, bool)>>) {
91        for z in chunk.get_min_z()..=chunk.get_max_z() {
92            let grid = Grid::populate_from(Vec2::new(32, 32), |v| {
93                let mut rgba = Rgba::<f32>::zero();
94                let (weights, zoff) = (&[1, 2, 4, 1, 1, 1][..], -2);
95                for (dz, weight) in weights.iter().enumerate() {
96                    let color = chunk
97                        .get(Vec3::new(v.x, v.y, dz as i32 + z + zoff))
98                        .ok()
99                        .and_then(Self::block_color)
100                        .unwrap_or_else(Rgba::zero);
101                    rgba += color.as_() * *weight as f32;
102                }
103                let rgba: Rgba<u8> = (rgba / weights.iter().map(|x| *x as f32).sum::<f32>()).as_();
104                (rgba, true)
105            });
106            layers.push(grid);
107        }
108    }
109
110    /// Each layer is the overhead as if its z-level were the ceiling
111    fn composite_layer_overhead(chunk: &TerrainChunk, layers: &mut Vec<Grid<(Rgba<u8>, bool)>>) {
112        for z in chunk.get_min_z()..=chunk.get_max_z() {
113            let grid = Grid::populate_from(Vec2::new(32, 32), |v| {
114                let mut rgba = None;
115
116                let mut seen_solids: u32 = 0;
117                let mut seen_air: u32 = 0;
118                for dz in chunk.get_min_z()..=z {
119                    if let Some(color) = chunk
120                        .get(Vec3::new(v.x, v.y, z - dz + chunk.get_min_z()))
121                        .ok()
122                        .and_then(Self::block_color)
123                    {
124                        if seen_air > 0 {
125                            rgba = Some(color);
126                            break;
127                        }
128                        seen_solids += 1;
129                    } else {
130                        seen_air += 1;
131                    }
132                    // Don't penetrate too far into ground, only penetrate through shallow
133                    // ceilings
134                    if seen_solids > 12 {
135                        break;
136                    }
137                }
138                let block = chunk.get(Vec3::new(v.x, v.y, z)).ok();
139                // Treat Leaves and Wood as translucent for the purposes of ceiling checks,
140                // since otherwise trees would cause ceiling removal to trigger
141                // when running under a branch.
142                let is_filled = block.is_none_or(|b| {
143                    b.is_filled()
144                        && !matches!(
145                            b.kind(),
146                            BlockKind::Leaves | BlockKind::ArtLeaves | BlockKind::Wood
147                        )
148                });
149                let rgba = rgba.unwrap_or_else(|| Rgba::new(0, 0, 0, 255));
150                (rgba, is_filled)
151            });
152            layers.push(grid);
153        }
154    }
155
156    fn add_chunks_near(
157        &mut self,
158        pool: &SlowJobPool,
159        terrain: &TerrainGrid,
160        cpos: Vec2<i32>,
161    ) -> bool {
162        let mut new_chunks = false;
163
164        for (key, chunk) in terrain.iter() {
165            let delta: Vec2<u32> = (key - cpos).map(i32::abs).as_();
166            if delta.x < VOXEL_MINIMAP_SIDELENGTH / TerrainChunkSize::RECT_SIZE.x
167                && delta.y < VOXEL_MINIMAP_SIDELENGTH / TerrainChunkSize::RECT_SIZE.y
168                && (!self.chunk_minimaps.contains_key(&key)
169                    || self.chunks_to_replace.contains(&key))
170            {
171                if let Some((_, column)) = self.keyed_jobs.spawn(Some(pool), key, || {
172                    let arc_chunk = Arc::clone(chunk);
173                    move |_| {
174                        let mut layers = Vec::new();
175                        const MODE_OVERHEAD: bool = true;
176                        if MODE_OVERHEAD {
177                            Self::composite_layer_overhead(&arc_chunk, &mut layers);
178                        } else {
179                            Self::composite_layer_slice(&arc_chunk, &mut layers);
180                        }
181                        let above = arc_chunk
182                            .get(Vec3::new(0, 0, arc_chunk.get_max_z() + 1))
183                            .ok()
184                            .copied()
185                            .unwrap_or_else(Block::empty);
186                        let below = arc_chunk
187                            .get(Vec3::new(0, 0, arc_chunk.get_min_z() - 1))
188                            .ok()
189                            .copied()
190                            .unwrap_or_else(Block::empty);
191                        MinimapColumn {
192                            zlo: arc_chunk.get_min_z(),
193                            layers,
194                            above: (
195                                Self::block_color(&above).unwrap_or_else(Rgba::zero),
196                                above.is_filled(),
197                            ),
198                            below: (
199                                Self::block_color(&below).unwrap_or_else(Rgba::zero),
200                                below.is_filled(),
201                            ),
202                        }
203                    }
204                }) {
205                    self.chunks_to_replace.remove(&key);
206                    self.chunk_minimaps.insert(key, column);
207                    new_chunks = true;
208                }
209            }
210        }
211        new_chunks
212    }
213
214    fn add_chunks_to_replace(&mut self, terrain: &TerrainGrid, changes: &TerrainChanges) {
215        changes
216            .modified_blocks
217            .iter()
218            .filter(|(key, old_block)| {
219                terrain
220                    .get(**key)
221                    .is_ok_and(|new_block| new_block.is_terrain() != old_block.is_terrain())
222            })
223            .map(|(key, _)| terrain.pos_key(*key))
224            .for_each(|key| {
225                self.chunks_to_replace.insert(key);
226            });
227    }
228
229    fn remove_chunks_far(&mut self, terrain: &TerrainGrid, cpos: Vec2<i32>) {
230        let key_predicate = |key: &Vec2<i32>| {
231            let delta: Vec2<u32> = (key - cpos).map(i32::abs).as_();
232            delta.x < 1 + VOXEL_MINIMAP_SIDELENGTH / TerrainChunkSize::RECT_SIZE.x
233                && delta.y < 1 + VOXEL_MINIMAP_SIDELENGTH / TerrainChunkSize::RECT_SIZE.y
234                && terrain.get_key(*key).is_some()
235        };
236        self.chunks_to_replace.retain(&key_predicate);
237        self.chunk_minimaps.retain(|key, _| key_predicate(key));
238    }
239
240    pub fn maintain(&mut self, client: &Client, ui: &mut Ui) {
241        let player = client.entity();
242        let pos = if let Some(pos) = client.state().ecs().read_storage::<comp::Pos>().get(player) {
243            pos.0
244        } else {
245            return;
246        };
247        let vpos = pos.xy() - VOXEL_MINIMAP_SIDELENGTH as f32 / 2.0;
248        let cpos: Vec2<i32> = vpos
249            .map2(TerrainChunkSize::RECT_SIZE, |i, j| (i as u32).div_euclid(j))
250            .as_();
251
252        let pool = client.state().ecs().read_resource::<SlowJobPool>();
253        let terrain = client.state().terrain();
254        let changed_blocks = client.state().terrain_changes();
255        self.add_chunks_to_replace(&terrain, &changed_blocks);
256        let new_chunks = self.add_chunks_near(&pool, &terrain, cpos);
257        self.remove_chunks_far(&terrain, cpos);
258
259        // ceiling_offset is the distance from the player to a block heuristically
260        // detected as the ceiling height (a non-tree solid block above them, or
261        // the sky if no such block exists). This is used for determining which
262        // z-slice of the minimap to show, such that house roofs and caves and
263        // dungeons are all handled uniformly.
264        let ceiling_offset = {
265            let voff = Vec2::new(
266                VOXEL_MINIMAP_SIDELENGTH as f32,
267                VOXEL_MINIMAP_SIDELENGTH as f32,
268            ) / 2.0;
269            let coff: Vec2<i32> = voff
270                .map2(TerrainChunkSize::RECT_SIZE, |i, j| (i as u32).div_euclid(j))
271                .as_();
272            let cmod: Vec2<i32> = vpos
273                .map2(TerrainChunkSize::RECT_SIZE, |i, j| (i as u32).rem_euclid(j))
274                .as_();
275            let column = self.chunk_minimaps.get(&(cpos + coff));
276            // TODO: evaluate clippy, toolchain upgrade 2021-12-19
277            #[expect(clippy::unnecessary_lazy_evaluations)]
278            column
279                .map(
280                    |MinimapColumn {
281                         zlo, layers, above, ..
282                     }| {
283                        (0..layers.len() as i32)
284                            .find(|dz| {
285                                layers
286                                    .get((pos.z as i32 - zlo + dz) as usize)
287                                    .and_then(|grid| grid.get(cmod)).is_some_and(|(_, b)| *b)
288                            })
289                            .unwrap_or_else(||
290                                // if the `find` returned None, there's no solid blocks above the
291                                // player within the chunk
292                                if above.1 {
293                                    // if the `above` block is solid, the chunk has an infinite
294                                    // solid ceiling, and so we render from 1 block above the
295                                    // player (which is where the player's head is if they're 2
296                                    // blocks tall)
297                                    1
298                                } else {
299                                    // if the ceiling is a non-solid sky, use the largest value
300                                    // (subsequent arithmetic on ceiling_offset must be saturating)
301                                    i32::MAX
302                                }
303                            )
304                    },
305                )
306                .unwrap_or(0)
307        };
308        if self.last_pos.xy() != cpos
309            || self.last_pos.z != pos.z as i32
310            || self.last_ceiling != ceiling_offset
311            || new_chunks
312        {
313            self.last_pos = cpos.with_z(pos.z as i32);
314            self.last_ceiling = ceiling_offset;
315            for y in 0..VOXEL_MINIMAP_SIDELENGTH {
316                for x in 0..VOXEL_MINIMAP_SIDELENGTH {
317                    let voff = Vec2::new(x as f32, y as f32);
318                    let coff: Vec2<i32> = voff
319                        .map2(TerrainChunkSize::RECT_SIZE, |i, j| (i as u32).div_euclid(j))
320                        .as_();
321                    let cmod: Vec2<i32> = voff
322                        .map2(TerrainChunkSize::RECT_SIZE, |i, j| (i as u32).rem_euclid(j))
323                        .as_();
324                    let column = self.chunk_minimaps.get(&(cpos + coff));
325                    let color: Rgba<u8> = column
326                        .and_then(|column| {
327                            let MinimapColumn {
328                                zlo,
329                                layers,
330                                above,
331                                below,
332                            } = column;
333                            if (pos.z as i32).saturating_add(ceiling_offset) < *zlo {
334                                // If the ceiling is below the bottom of a chunk, color it black,
335                                // so that the middles of caves/dungeons don't show the forests
336                                // around them.
337                                Some(Rgba::new(0, 0, 0, 255))
338                            } else {
339                                // Otherwise, take the pixel from the precomputed z-level view at
340                                // the ceiling's height (using the top slice of the chunk if the
341                                // ceiling is above the chunk, (e.g. so that forests with
342                                // differently-tall trees are handled properly)
343                                // TODO: evaluate clippy, toolchain upgrade 2021-12-19
344                                #[expect(clippy::unnecessary_lazy_evaluations)]
345                                layers
346                                    .get(
347                                        (((pos.z as i32 - zlo).saturating_add(ceiling_offset))
348                                            as usize)
349                                            .min(layers.len().saturating_sub(1)),
350                                    )
351                                    .and_then(|grid| grid.get(cmod).map(|c| c.0.as_()))
352                                    .or_else(|| {
353                                        Some(if pos.z as i32 > *zlo {
354                                            above.0
355                                        } else {
356                                            below.0
357                                        })
358                                    })
359                            }
360                        })
361                        .unwrap_or_else(Rgba::zero);
362                    self.composited.put_pixel(
363                        x,
364                        VOXEL_MINIMAP_SIDELENGTH - y - 1,
365                        image::Rgba([color.r, color.g, color.b, color.a]),
366                    );
367                }
368            }
369
370            ui.replace_graphic(
371                self.image_id.none,
372                Graphic::Image(
373                    Arc::new(DynamicImage::ImageRgba8(self.composited.clone())),
374                    Some(Rgba::from([0.0, 0.0, 0.0, 0.0])),
375                ),
376            );
377        }
378    }
379}
380
381widget_ids! {
382    struct Ids {
383        mmap_frame,
384        mmap_frame_2,
385        mmap_frame_bg,
386        mmap_location,
387        mmap_button,
388        mmap_plus,
389        mmap_minus,
390        mmap_north_button,
391        map_layers[],
392        indicator,
393        mmap_north,
394        mmap_east,
395        mmap_south,
396        mmap_west,
397        mmap_site_icons_bgs[],
398        mmap_site_icons[],
399        member_indicators[],
400        location_marker,
401        location_marker_group[],
402        voxel_minimap,
403    }
404}
405
406#[derive(WidgetCommon)]
407pub struct MiniMap<'a> {
408    client: &'a Client,
409    imgs: &'a Imgs,
410    rot_imgs: &'a ImgsRot,
411    world_map: &'a (Vec<img_ids::Rotations>, Vec2<u32>),
412    fonts: &'a Fonts,
413    #[conrod(common_builder)]
414    common: widget::CommonBuilder,
415    ori: Vec3<f32>,
416    global_state: &'a GlobalState,
417    location_markers: &'a MapMarkers,
418    voxel_minimap: &'a VoxelMinimap,
419}
420
421impl<'a> MiniMap<'a> {
422    pub fn new(
423        client: &'a Client,
424        imgs: &'a Imgs,
425        rot_imgs: &'a ImgsRot,
426        world_map: &'a (Vec<img_ids::Rotations>, Vec2<u32>),
427        fonts: &'a Fonts,
428        ori: Vec3<f32>,
429        global_state: &'a GlobalState,
430        location_markers: &'a MapMarkers,
431        voxel_minimap: &'a VoxelMinimap,
432    ) -> Self {
433        Self {
434            client,
435            imgs,
436            rot_imgs,
437            world_map,
438            fonts,
439            common: widget::CommonBuilder::default(),
440            ori,
441            global_state,
442            location_markers,
443            voxel_minimap,
444        }
445    }
446}
447
448pub struct State {
449    ids: Ids,
450}
451
452pub enum Event {
453    SettingsChange(InterfaceChange),
454}
455
456impl Widget for MiniMap<'_> {
457    type Event = Vec<Event>;
458    type State = State;
459    type Style = ();
460
461    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
462        State {
463            ids: Ids::new(id_gen),
464        }
465    }
466
467    fn style(&self) -> Self::Style {}
468
469    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
470        common_base::prof_span!("Minimap::update");
471        let mut events = Vec::new();
472
473        let widget::UpdateArgs { state, ui, .. } = args;
474        let mut zoom = self.global_state.settings.interface.minimap_zoom;
475        const SCALE: f64 = 1.5; // TODO Make this a setting
476        let show_minimap = self.global_state.settings.interface.minimap_show;
477        let is_facing_north = self.global_state.settings.interface.minimap_face_north;
478        let show_topo_map = self.global_state.settings.interface.map_show_topo_map;
479        let show_voxel_map = self.global_state.settings.interface.map_show_voxel_map;
480        let orientation = if is_facing_north {
481            Vec3::new(0.0, 1.0, 0.0)
482        } else {
483            self.ori
484        };
485
486        if show_minimap {
487            Image::new(self.imgs.mmap_frame)
488                .w_h(174.0 * SCALE, 190.0 * SCALE)
489                .top_right_with_margins_on(ui.window, 5.0, 5.0)
490                .color(Some(UI_MAIN))
491                .set(state.ids.mmap_frame, ui);
492            Image::new(self.imgs.mmap_frame_2)
493                .w_h(174.0 * SCALE, 190.0 * SCALE)
494                .middle_of(state.ids.mmap_frame)
495                .color(Some(UI_HIGHLIGHT_0))
496                .set(state.ids.mmap_frame_2, ui);
497            Rectangle::fill_with([170.0 * SCALE, 170.0 * SCALE], color::TRANSPARENT)
498                .mid_top_with_margin_on(state.ids.mmap_frame_2, 18.0 * SCALE)
499                .set(state.ids.mmap_frame_bg, ui);
500
501            // Map size in chunk coords
502            let worldsize = self.world_map.1;
503            // Map Layers
504            // It is assumed that there is at least one layer
505            if state.ids.map_layers.len() < self.world_map.0.len() {
506                state.update(|state| {
507                    state
508                        .ids
509                        .map_layers
510                        .resize(self.world_map.0.len(), &mut ui.widget_id_generator())
511                });
512            }
513
514            // Zoom Buttons
515
516            // Pressing + multiplies, and - divides, zoom by ZOOM_FACTOR.
517            const ZOOM_FACTOR: f64 = 2.0;
518
519            // TODO: Either prevent zooming all the way in, *or* see if we can interpolate
520            // somehow if you zoom in too far.  Or both.
521            let min_zoom = 1.0;
522            let max_zoom = worldsize
523                .reduce_partial_max() as f64/*.min(f64::MAX)*/;
524
525            // NOTE: Not sure if a button can be clicked while disabled, but we still double
526            // check for both kinds of zoom to make sure that not only was the
527            // button clicked, it is also okay to perform the zoom action.
528            // Note that since `Button::image` has side effects, we must perform
529            // the `can_zoom_in` and `can_zoom_out` checks after the `&&` to avoid
530            // undesired early termination.
531            let can_zoom_in = zoom < max_zoom;
532            let can_zoom_out = zoom > min_zoom;
533
534            if Button::image(self.imgs.mmap_minus)
535                .w_h(16.0 * SCALE, 18.0 * SCALE)
536                .hover_image(self.imgs.mmap_minus_hover)
537                .press_image(self.imgs.mmap_minus_press)
538                .top_left_with_margins_on(state.ids.mmap_frame, 0.0, 0.0)
539                .image_color(UI_HIGHLIGHT_0)
540                .enabled(can_zoom_out)
541                .set(state.ids.mmap_minus, ui)
542                .was_clicked()
543                && can_zoom_out
544            {
545                // Set the image dimensions here, rather than recomputing each time.
546                zoom = min_zoom.max(zoom / ZOOM_FACTOR);
547                // set_image_dims(zoom);
548                events.push(Event::SettingsChange(MinimapZoom(zoom)));
549            }
550            if Button::image(self.imgs.mmap_plus)
551                .w_h(18.0 * SCALE, 18.0 * SCALE)
552                .hover_image(self.imgs.mmap_plus_hover)
553                .press_image(self.imgs.mmap_plus_press)
554                .right_from(state.ids.mmap_minus, 0.0)
555                .image_color(UI_HIGHLIGHT_0)
556                .enabled(can_zoom_in)
557                .set(state.ids.mmap_plus, ui)
558                .was_clicked()
559                && can_zoom_in
560            {
561                zoom = min_zoom.max(zoom * ZOOM_FACTOR);
562                // set_image_dims(zoom);
563                events.push(Event::SettingsChange(MinimapZoom(zoom)));
564            }
565
566            // Always northfacing button
567            if Button::image(if is_facing_north {
568                self.imgs.mmap_north_press
569            } else {
570                self.imgs.mmap_north
571            })
572            .w_h(18.0 * SCALE, 18.0 * SCALE)
573            .hover_image(if is_facing_north {
574                self.imgs.mmap_north_press_hover
575            } else {
576                self.imgs.mmap_north_hover
577            })
578            .press_image(if is_facing_north {
579                self.imgs.mmap_north_press_hover
580            } else {
581                self.imgs.mmap_north_press
582            })
583            .left_from(state.ids.mmap_button, 0.0)
584            .image_color(UI_HIGHLIGHT_0)
585            .set(state.ids.mmap_north_button, ui)
586            .was_clicked()
587            {
588                events.push(Event::SettingsChange(MinimapFaceNorth(!is_facing_north)));
589            }
590
591            // Coordinates
592            let player_pos = self
593                .client
594                .state()
595                .ecs()
596                .read_storage::<comp::Pos>()
597                .get(self.client.entity())
598                .map_or(Vec3::zero(), |pos| pos.0);
599
600            // Get map image source rectangle dimensions.
601            let w_src = max_zoom / zoom;
602            let h_src = max_zoom / zoom;
603
604            // Set map image to be centered around player coordinates.
605            let rect_src = position::Rect::from_xy_dim(
606                [
607                    player_pos.x as f64 / TerrainChunkSize::RECT_SIZE.x as f64,
608                    worldsize.y as f64
609                        - (player_pos.y as f64 / TerrainChunkSize::RECT_SIZE.y as f64),
610                ],
611                [w_src, h_src],
612            );
613
614            let map_size = Vec2::new(170.0 * SCALE, 170.0 * SCALE);
615
616            // Map Image
617            // Map Layer Images
618            for (index, layer) in self.world_map.0.iter().enumerate() {
619                let world_map_rotation = if is_facing_north {
620                    layer.none
621                } else {
622                    layer.source_north
623                };
624                if index == 0 {
625                    Image::new(world_map_rotation)
626                        .middle_of(state.ids.mmap_frame_bg)
627                        .w_h(map_size.x, map_size.y)
628                        .parent(state.ids.mmap_frame_bg)
629                        .source_rectangle(rect_src)
630                        .set(state.ids.map_layers[index], ui);
631                } else if show_topo_map {
632                    Image::new(world_map_rotation)
633                        .middle_of(state.ids.mmap_frame_bg)
634                        .w_h(map_size.x, map_size.y)
635                        .parent(state.ids.mmap_frame_bg)
636                        .source_rectangle(rect_src)
637                        .graphics_for(state.ids.map_layers[0])
638                        .set(state.ids.map_layers[index], ui);
639                }
640            }
641            if show_voxel_map {
642                let voxelmap_rotation = if is_facing_north {
643                    self.voxel_minimap.image_id.none
644                } else {
645                    self.voxel_minimap.image_id.source_north
646                };
647                let cmod: Vec2<f64> = player_pos
648                    .xy()
649                    .map2(TerrainChunkSize::RECT_SIZE, |i, j| (i as u32).rem_euclid(j))
650                    .as_();
651                let rect_src = position::Rect::from_xy_dim(
652                    [
653                        cmod.x + VOXEL_MINIMAP_SIDELENGTH as f64 / 2.0,
654                        -cmod.y + VOXEL_MINIMAP_SIDELENGTH as f64 / 2.0,
655                    ],
656                    [
657                        TerrainChunkSize::RECT_SIZE.x as f64 * max_zoom / zoom,
658                        TerrainChunkSize::RECT_SIZE.y as f64 * max_zoom / zoom,
659                    ],
660                );
661                Image::new(voxelmap_rotation)
662                    .middle_of(state.ids.mmap_frame_bg)
663                    .w_h(map_size.x, map_size.y)
664                    .parent(state.ids.mmap_frame_bg)
665                    .source_rectangle(rect_src)
666                    .graphics_for(state.ids.map_layers[0])
667                    .set(state.ids.voxel_minimap, ui);
668            }
669
670            // Map icons
671            if state.ids.mmap_site_icons.len() < self.client.sites().len() {
672                state.update(|state| {
673                    state
674                        .ids
675                        .mmap_site_icons
676                        .resize(self.client.sites().len(), &mut ui.widget_id_generator())
677                });
678            }
679            if state.ids.mmap_site_icons_bgs.len() < self.client.sites().len() {
680                state.update(|state| {
681                    state
682                        .ids
683                        .mmap_site_icons_bgs
684                        .resize(self.client.sites().len(), &mut ui.widget_id_generator())
685                });
686            }
687
688            let wpos_to_rpos = |wpos: Vec2<f32>, limit: bool| {
689                // Site pos in world coordinates relative to the player
690                let rwpos = wpos - player_pos;
691                // Convert to chunk coordinates
692                let rcpos = rwpos.wpos_to_cpos();
693                // Convert to fractional coordinates relative to the worldsize
694                let rfpos = rcpos / max_zoom as f32;
695                // Convert to unrotated pixel coordinates from the player location on the map
696                // (the center)
697                // Accounting for zooming
698                let rpixpos = rfpos.map2(map_size, |e, sz| e * sz as f32 * zoom as f32);
699                let rpos = Vec2::unit_x().rotated_z(orientation.x) * rpixpos.x
700                    + Vec2::unit_y().rotated_z(orientation.x) * rpixpos.y;
701
702                if rpos
703                    .map2(map_size, |e, sz| e.abs() > sz as f32 / 2.0)
704                    .reduce_or()
705                {
706                    limit.then(|| {
707                        let clamped = rpos / rpos.map(|e| e.abs()).reduce_partial_max();
708                        clamped * map_size.map(|e| e as f32) / 2.0
709                    })
710                } else {
711                    Some(rpos)
712                }
713            };
714
715            for (i, site_rich) in self.client.sites().values().enumerate() {
716                let site = &site_rich.site;
717
718                let rpos = match wpos_to_rpos(site.wpos.map(|e| e as f32), false) {
719                    Some(rpos) => rpos,
720                    None => continue,
721                };
722                let difficulty = match &site.kind {
723                    SiteKind::Town => None,
724                    SiteKind::ChapelSite => Some(4),
725                    SiteKind::Terracotta => Some(5),
726                    SiteKind::Castle => None,
727                    SiteKind::Cave => None,
728                    SiteKind::Tree => None,
729                    SiteKind::Gnarling => Some(0),
730                    SiteKind::Bridge | SiteKind::GliderCourse => None,
731                    SiteKind::Adlet => Some(1),
732                    SiteKind::Sahagin => Some(2),
733                    SiteKind::Haniwa => Some(3),
734                    SiteKind::Cultist => Some(5),
735                    SiteKind::Myrmidon => Some(4),
736                    SiteKind::DwarvenMine => Some(5),
737                    SiteKind::VampireCastle => Some(2),
738                };
739
740                Image::new(match &site.kind {
741                    SiteKind::Town => self.imgs.mmap_site_town_bg,
742                    SiteKind::ChapelSite => self.imgs.mmap_site_sea_chapel_bg,
743                    SiteKind::Terracotta => self.imgs.mmap_site_terracotta_bg,
744                    SiteKind::Castle => self.imgs.mmap_site_castle_bg,
745                    SiteKind::Cave => self.imgs.mmap_site_cave_bg,
746                    SiteKind::Tree => self.imgs.mmap_site_tree,
747                    SiteKind::Gnarling => self.imgs.mmap_site_gnarling_bg,
748                    SiteKind::Bridge => self.imgs.mmap_site_bridge_bg,
749                    SiteKind::GliderCourse => self.imgs.mmap_site_glider_course_bg,
750                    SiteKind::Adlet => self.imgs.mmap_site_adlet_bg,
751                    SiteKind::Haniwa => self.imgs.mmap_site_haniwa_bg,
752                    SiteKind::Cultist => self.imgs.mmap_site_cultist_bg,
753                    SiteKind::Sahagin => self.imgs.mmap_site_sahagin_bg,
754                    SiteKind::Myrmidon => self.imgs.mmap_site_myrmidon_bg,
755                    SiteKind::DwarvenMine => self.imgs.mmap_site_mine_bg,
756                    SiteKind::VampireCastle => self.imgs.mmap_site_vampire_castle_bg,
757                })
758                .x_y_position_relative_to(
759                    state.ids.map_layers[0],
760                    position::Relative::Scalar(rpos.x as f64),
761                    position::Relative::Scalar(rpos.y as f64),
762                )
763                .w_h(20.0, 20.0)
764                .color(Some(match difficulty {
765                    Some(0) => QUALITY_LOW,
766                    Some(1) => QUALITY_COMMON,
767                    Some(2) => QUALITY_MODERATE,
768                    Some(3) => QUALITY_HIGH,
769                    Some(4) => QUALITY_EPIC,
770                    Some(5) => QUALITY_DEBUG,
771                    _ => Color::Rgba(1.0, 1.0, 1.0, 0.0),
772                }))
773                .set(state.ids.mmap_site_icons_bgs[i], ui);
774                Image::new(match &site.kind {
775                    SiteKind::Town => self.imgs.mmap_site_town,
776                    SiteKind::ChapelSite => self.imgs.mmap_site_sea_chapel,
777                    SiteKind::Terracotta => self.imgs.mmap_site_terracotta,
778                    SiteKind::Castle => self.imgs.mmap_site_castle,
779                    SiteKind::Cave => self.imgs.mmap_site_cave,
780                    SiteKind::Tree => self.imgs.mmap_site_tree,
781                    SiteKind::Gnarling => self.imgs.mmap_site_gnarling,
782                    SiteKind::Bridge => self.imgs.mmap_site_bridge,
783                    SiteKind::GliderCourse => self.imgs.mmap_site_glider_course,
784                    SiteKind::Adlet => self.imgs.mmap_site_adlet,
785                    SiteKind::Haniwa => self.imgs.mmap_site_haniwa,
786                    SiteKind::Cultist => self.imgs.mmap_site_cultist,
787                    SiteKind::Sahagin => self.imgs.mmap_site_sahagin,
788                    SiteKind::Myrmidon => self.imgs.mmap_site_myrmidon,
789                    SiteKind::DwarvenMine => self.imgs.mmap_site_mine,
790                    SiteKind::VampireCastle => self.imgs.mmap_site_vampire_castle,
791                })
792                .middle_of(state.ids.mmap_site_icons_bgs[i])
793                .w_h(20.0, 20.0)
794                .color(Some(UI_HIGHLIGHT_0))
795                .set(state.ids.mmap_site_icons[i], ui);
796            }
797
798            // Group member indicators
799            let client_state = self.client.state();
800            let member_pos = client_state.ecs().read_storage::<comp::Pos>();
801            let group_members = self
802                .client
803                .group_members()
804                .iter()
805                .filter_map(|(u, r)| match r {
806                    Role::Member => Some(u),
807                    Role::Pet => None,
808                })
809                .collect::<Vec<_>>();
810            let group_size = group_members.len();
811            //let in_group = !group_members.is_empty();
812            let id_maps = client_state
813                .ecs()
814                .read_resource::<common_net::sync::IdMaps>();
815            if state.ids.member_indicators.len() < group_size {
816                state.update(|s| {
817                    s.ids
818                        .member_indicators
819                        .resize(group_size, &mut ui.widget_id_generator())
820                })
821            };
822            for (i, &uid) in group_members.iter().copied().enumerate() {
823                let entity = id_maps.uid_entity(uid);
824                let member_pos = entity.and_then(|entity| member_pos.get(entity));
825
826                if let Some(member_pos) = member_pos {
827                    let rpos = match wpos_to_rpos(member_pos.0.xy(), false) {
828                        Some(rpos) => rpos,
829                        None => continue,
830                    };
831
832                    let factor = 1.2;
833                    let z_comparison = (member_pos.0.z - player_pos.z) as i32;
834                    Button::image(match z_comparison {
835                        10..=i32::MAX => self.imgs.indicator_group_up,
836                        i32::MIN..=-10 => self.imgs.indicator_group_down,
837                        _ => self.imgs.indicator_group,
838                    })
839                    .x_y_position_relative_to(
840                        state.ids.map_layers[0],
841                        position::Relative::Scalar(rpos.x as f64),
842                        position::Relative::Scalar(rpos.y as f64),
843                    )
844                    .w_h(16.0 * factor, 16.0 * factor)
845                    .image_color(Color::Rgba(1.0, 1.0, 1.0, 1.0))
846                    .set(state.ids.member_indicators[i], ui);
847                }
848            }
849
850            // Group location markers
851            if state.ids.location_marker_group.len() < self.location_markers.group.len() {
852                state.update(|s| {
853                    s.ids.location_marker_group.resize(
854                        self.location_markers.group.len(),
855                        &mut ui.widget_id_generator(),
856                    )
857                })
858            };
859            for (i, (&uid, &rpos)) in self.location_markers.group.iter().enumerate() {
860                let lm = rpos.as_();
861                if let Some(rpos) = wpos_to_rpos(lm, true) {
862                    let (image_id, factor) = match self.client.group_info().map(|info| info.1) {
863                        Some(leader) if leader == uid => {
864                            (self.imgs.location_marker_group_leader, 1.2)
865                        },
866                        _ => (self.imgs.location_marker_group, 1.0),
867                    };
868
869                    Image::new(image_id)
870                        .x_y_position_relative_to(
871                            state.ids.map_layers[0],
872                            position::Relative::Scalar(rpos.x as f64),
873                            position::Relative::Scalar(rpos.y as f64 + 8.0 * factor),
874                        )
875                        .w_h(16.0 * factor, 16.0 * factor)
876                        .parent(ui.window)
877                        .set(state.ids.location_marker_group[i], ui)
878                }
879            }
880
881            // Location marker
882            if let Some(rpos) = self
883                .location_markers
884                .owned
885                .and_then(|lm| wpos_to_rpos(lm.as_(), true))
886            {
887                let factor = 1.2;
888
889                Image::new(self.imgs.location_marker)
890                    .x_y_position_relative_to(
891                        state.ids.map_layers[0],
892                        position::Relative::Scalar(rpos.x as f64),
893                        position::Relative::Scalar(rpos.y as f64 + 8.0 * factor),
894                    )
895                    .w_h(16.0 * factor, 16.0 * factor)
896                    .parent(ui.window)
897                    .set(state.ids.location_marker, ui)
898            }
899            // Indicator
900            let ind_scale = 0.4;
901            let ind_rotation = if is_facing_north {
902                self.rot_imgs.indicator_mmap_small.target_north
903            } else {
904                self.rot_imgs.indicator_mmap_small.none
905            };
906            Image::new(ind_rotation)
907                .middle_of(state.ids.map_layers[0])
908                .w_h(32.0 * ind_scale, 37.0 * ind_scale)
909                .color(Some(UI_HIGHLIGHT_0))
910                .parent(ui.window)
911                .set(state.ids.indicator, ui);
912
913            // Compass directions
914            let dirs = [
915                (Vec2::new(0.0, 1.0), state.ids.mmap_north, "N", true),
916                (Vec2::new(1.0, 0.0), state.ids.mmap_east, "E", false),
917                (Vec2::new(0.0, -1.0), state.ids.mmap_south, "S", false),
918                (Vec2::new(-1.0, 0.0), state.ids.mmap_west, "W", false),
919            ];
920            for (dir, id, name, bold) in dirs.iter() {
921                let cardinal_dir = Vec2::unit_x().rotated_z(orientation.x as f64) * dir.x
922                    + Vec2::unit_y().rotated_z(orientation.x as f64) * dir.y;
923                let clamped = cardinal_dir / cardinal_dir.map(|e| e.abs()).reduce_partial_max();
924                let pos = clamped * (map_size / 2.0 - 10.0);
925                Text::new(name)
926                    .x_y_position_relative_to(
927                        state.ids.map_layers[0],
928                        position::Relative::Scalar(pos.x),
929                        position::Relative::Scalar(pos.y),
930                    )
931                    .font_size(self.fonts.cyri.scale(18))
932                    .font_id(self.fonts.cyri.conrod_id)
933                    .color(if *bold {
934                        Color::Rgba(0.75, 0.0, 0.0, 1.0)
935                    } else {
936                        TEXT_COLOR
937                    })
938                    .parent(ui.window)
939                    .set(*id, ui);
940            }
941        } else {
942            Image::new(self.imgs.mmap_frame_closed)
943                .w_h(174.0 * SCALE, 18.0 * SCALE)
944                .color(Some(UI_MAIN))
945                .top_right_with_margins_on(ui.window, 0.0, 5.0)
946                .set(state.ids.mmap_frame, ui);
947        }
948
949        if Button::image(if show_minimap {
950            self.imgs.mmap_open
951        } else {
952            self.imgs.mmap_closed
953        })
954        .w_h(18.0 * SCALE, 18.0 * SCALE)
955        .hover_image(if show_minimap {
956            self.imgs.mmap_open_hover
957        } else {
958            self.imgs.mmap_closed_hover
959        })
960        .press_image(if show_minimap {
961            self.imgs.mmap_open_press
962        } else {
963            self.imgs.mmap_closed_press
964        })
965        .top_right_with_margins_on(state.ids.mmap_frame, 0.0, 0.0)
966        .image_color(UI_HIGHLIGHT_0)
967        .set(state.ids.mmap_button, ui)
968        .was_clicked()
969        {
970            events.push(Event::SettingsChange(MinimapShow(!show_minimap)));
971        }
972
973        // TODO: Subregion name display
974
975        // Title
976
977        match self.client.current_chunk() {
978            Some(chunk) => {
979                // Count characters in the name to avoid clipping with the name display
980                if let Some(name) = chunk.meta().name() {
981                    let name_len = name.chars().count();
982                    Text::new(name)
983                        .mid_top_with_margin_on(state.ids.mmap_frame, match name_len {
984                            15..=30 => 4.0,
985                            _ => 2.0,
986                        })
987                        .font_size(self.fonts.cyri.scale(match name_len {
988                            0..=15 => 18,
989                            16..=30 => 14,
990                            _ => 14,
991                        }))
992                        .font_id(self.fonts.cyri.conrod_id)
993                        .color(TEXT_COLOR)
994                        .set(state.ids.mmap_location, ui)
995                }
996            },
997            None => Text::new(" ")
998                .mid_top_with_margin_on(state.ids.mmap_frame, 0.0)
999                .font_size(self.fonts.cyri.scale(18))
1000                .color(TEXT_COLOR)
1001                .set(state.ids.mmap_location, ui),
1002        }
1003
1004        events
1005    }
1006}