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::{Marker, MarkerKind};
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    extra_markers: &'a HashMap<Vec2<i32>, Marker>,
420}
421
422impl<'a> MiniMap<'a> {
423    pub fn new(
424        client: &'a Client,
425        imgs: &'a Imgs,
426        rot_imgs: &'a ImgsRot,
427        world_map: &'a (Vec<img_ids::Rotations>, Vec2<u32>),
428        fonts: &'a Fonts,
429        ori: Vec3<f32>,
430        global_state: &'a GlobalState,
431        location_markers: &'a MapMarkers,
432        voxel_minimap: &'a VoxelMinimap,
433        extra_markers: &'a HashMap<Vec2<i32>, Marker>,
434    ) -> Self {
435        Self {
436            client,
437            imgs,
438            rot_imgs,
439            world_map,
440            fonts,
441            common: widget::CommonBuilder::default(),
442            ori,
443            global_state,
444            location_markers,
445            voxel_minimap,
446            extra_markers,
447        }
448    }
449}
450
451pub struct State {
452    ids: Ids,
453}
454
455pub enum Event {
456    SettingsChange(InterfaceChange),
457}
458
459impl Widget for MiniMap<'_> {
460    type Event = Vec<Event>;
461    type State = State;
462    type Style = ();
463
464    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
465        State {
466            ids: Ids::new(id_gen),
467        }
468    }
469
470    fn style(&self) -> Self::Style {}
471
472    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
473        common_base::prof_span!("Minimap::update");
474        let mut events = Vec::new();
475
476        let widget::UpdateArgs { state, ui, .. } = args;
477        let mut zoom = self.global_state.settings.interface.minimap_zoom;
478        const SCALE: f64 = 1.5; // TODO Make this a setting
479        let show_minimap = self.global_state.settings.interface.minimap_show;
480        let is_facing_north = self.global_state.settings.interface.minimap_face_north;
481        let show_topo_map = self.global_state.settings.interface.map_show_topo_map;
482        let show_voxel_map = self.global_state.settings.interface.map_show_voxel_map;
483        let orientation = if is_facing_north {
484            Vec3::new(0.0, 1.0, 0.0)
485        } else {
486            self.ori
487        };
488
489        if show_minimap {
490            Image::new(self.imgs.mmap_frame)
491                .w_h(174.0 * SCALE, 190.0 * SCALE)
492                .top_right_with_margins_on(ui.window, 5.0, 5.0)
493                .color(Some(UI_MAIN))
494                .set(state.ids.mmap_frame, ui);
495            Image::new(self.imgs.mmap_frame_2)
496                .w_h(174.0 * SCALE, 190.0 * SCALE)
497                .middle_of(state.ids.mmap_frame)
498                .color(Some(UI_HIGHLIGHT_0))
499                .set(state.ids.mmap_frame_2, ui);
500            Rectangle::fill_with([170.0 * SCALE, 170.0 * SCALE], color::TRANSPARENT)
501                .mid_top_with_margin_on(state.ids.mmap_frame_2, 18.0 * SCALE)
502                .set(state.ids.mmap_frame_bg, ui);
503
504            // Map size in chunk coords
505            let worldsize = self.world_map.1;
506            // Map Layers
507            // It is assumed that there is at least one layer
508            if state.ids.map_layers.len() < self.world_map.0.len() {
509                state.update(|state| {
510                    state
511                        .ids
512                        .map_layers
513                        .resize(self.world_map.0.len(), &mut ui.widget_id_generator())
514                });
515            }
516
517            // Zoom Buttons
518            // Don't forget to update the code in src/mop/hud.rs:4728 in the
519            // `handle_map_zoom` fn when updating this! TODO: Consolidate
520            // minimap zooming, because having duplicate handlers for hotkey and interface
521            // is error prone. Find the other occurrence by searching for this comment.
522
523            // Pressing + multiplies, and - divides, zoom by ZOOM_FACTOR.
524            const ZOOM_FACTOR: f64 = 2.0;
525
526            // TODO: Either prevent zooming all the way in, *or* see if we can interpolate
527            // somehow if you zoom in too far.  Or both.
528            let min_zoom = 1.0;
529            let max_zoom = worldsize
530                .reduce_partial_max() as f64/*.min(f64::MAX)*/;
531
532            // NOTE: Not sure if a button can be clicked while disabled, but we still double
533            // check for both kinds of zoom to make sure that not only was the
534            // button clicked, it is also okay to perform the zoom action.
535            // Note that since `Button::image` has side effects, we must perform
536            // the `can_zoom_in` and `can_zoom_out` checks after the `&&` to avoid
537            // undesired early termination.
538            let can_zoom_in = zoom < max_zoom;
539            let can_zoom_out = zoom > min_zoom;
540
541            if Button::image(self.imgs.mmap_minus)
542                .w_h(16.0 * SCALE, 18.0 * SCALE)
543                .hover_image(self.imgs.mmap_minus_hover)
544                .press_image(self.imgs.mmap_minus_press)
545                .top_left_with_margins_on(state.ids.mmap_frame, 0.0, 0.0)
546                .image_color(UI_HIGHLIGHT_0)
547                .enabled(can_zoom_out)
548                .set(state.ids.mmap_minus, ui)
549                .was_clicked()
550                && can_zoom_out
551            {
552                // Set the image dimensions here, rather than recomputing each time.
553                zoom = (zoom / ZOOM_FACTOR).clamp(min_zoom, max_zoom);
554                // set_image_dims(zoom);
555                events.push(Event::SettingsChange(MinimapZoom(zoom)));
556            }
557
558            if Button::image(self.imgs.mmap_plus)
559                .w_h(18.0 * SCALE, 18.0 * SCALE)
560                .hover_image(self.imgs.mmap_plus_hover)
561                .press_image(self.imgs.mmap_plus_press)
562                .right_from(state.ids.mmap_minus, 0.0)
563                .image_color(UI_HIGHLIGHT_0)
564                .enabled(can_zoom_in)
565                .set(state.ids.mmap_plus, ui)
566                .was_clicked()
567                && can_zoom_in
568            {
569                zoom = (zoom * ZOOM_FACTOR).clamp(min_zoom, max_zoom);
570                // set_image_dims(zoom);
571                events.push(Event::SettingsChange(MinimapZoom(zoom)));
572            }
573
574            // Always northfacing button
575            if Button::image(if is_facing_north {
576                self.imgs.mmap_north_press
577            } else {
578                self.imgs.mmap_north
579            })
580            .w_h(18.0 * SCALE, 18.0 * SCALE)
581            .hover_image(if is_facing_north {
582                self.imgs.mmap_north_press_hover
583            } else {
584                self.imgs.mmap_north_hover
585            })
586            .press_image(if is_facing_north {
587                self.imgs.mmap_north_press_hover
588            } else {
589                self.imgs.mmap_north_press
590            })
591            .left_from(state.ids.mmap_button, 0.0)
592            .image_color(UI_HIGHLIGHT_0)
593            .set(state.ids.mmap_north_button, ui)
594            .was_clicked()
595            {
596                events.push(Event::SettingsChange(MinimapFaceNorth(!is_facing_north)));
597            }
598
599            // Coordinates
600            let player_pos = self
601                .client
602                .state()
603                .ecs()
604                .read_storage::<comp::Pos>()
605                .get(self.client.entity())
606                .map_or(Vec3::zero(), |pos| pos.0);
607
608            // Get map image source rectangle dimensions.
609            let w_src = max_zoom / zoom;
610            let h_src = max_zoom / zoom;
611
612            // Set map image to be centered around player coordinates.
613            let rect_src = position::Rect::from_xy_dim(
614                [
615                    player_pos.x as f64 / TerrainChunkSize::RECT_SIZE.x as f64,
616                    worldsize.y as f64
617                        - (player_pos.y as f64 / TerrainChunkSize::RECT_SIZE.y as f64),
618                ],
619                [w_src, h_src],
620            );
621
622            let map_size = Vec2::new(170.0 * SCALE, 170.0 * SCALE);
623
624            // Map Image
625            // Map Layer Images
626            for (index, layer) in self.world_map.0.iter().enumerate() {
627                let world_map_rotation = if is_facing_north {
628                    layer.none
629                } else {
630                    layer.source_north
631                };
632                if index == 0 {
633                    Image::new(world_map_rotation)
634                        .middle_of(state.ids.mmap_frame_bg)
635                        .w_h(map_size.x, map_size.y)
636                        .parent(state.ids.mmap_frame_bg)
637                        .source_rectangle(rect_src)
638                        .set(state.ids.map_layers[index], ui);
639                } else if show_topo_map {
640                    Image::new(world_map_rotation)
641                        .middle_of(state.ids.mmap_frame_bg)
642                        .w_h(map_size.x, map_size.y)
643                        .parent(state.ids.mmap_frame_bg)
644                        .source_rectangle(rect_src)
645                        .graphics_for(state.ids.map_layers[0])
646                        .set(state.ids.map_layers[index], ui);
647                }
648            }
649            if show_voxel_map {
650                let voxelmap_rotation = if is_facing_north {
651                    self.voxel_minimap.image_id.none
652                } else {
653                    self.voxel_minimap.image_id.source_north
654                };
655                let cmod: Vec2<f64> = player_pos.xy().map2(TerrainChunkSize::RECT_SIZE, |i, j| {
656                    (i as f64).rem_euclid(j as f64)
657                });
658                let rect_src = position::Rect::from_xy_dim(
659                    [
660                        cmod.x + VOXEL_MINIMAP_SIDELENGTH as f64 / 2.0,
661                        -cmod.y + VOXEL_MINIMAP_SIDELENGTH as f64 / 2.0,
662                    ],
663                    [
664                        TerrainChunkSize::RECT_SIZE.x as f64 * max_zoom / zoom,
665                        TerrainChunkSize::RECT_SIZE.y as f64 * max_zoom / zoom,
666                    ],
667                );
668                Image::new(voxelmap_rotation)
669                    .middle_of(state.ids.mmap_frame_bg)
670                    .w_h(map_size.x, map_size.y)
671                    .parent(state.ids.mmap_frame_bg)
672                    .source_rectangle(rect_src)
673                    .graphics_for(state.ids.map_layers[0])
674                    .set(state.ids.voxel_minimap, ui);
675            }
676
677            let markers = self
678                .client
679                .markers()
680                .chain(self.extra_markers.values())
681                .collect::<Vec<_>>();
682
683            // Map icons
684            if state.ids.mmap_site_icons.len() < markers.len() {
685                state.update(|state| {
686                    state
687                        .ids
688                        .mmap_site_icons
689                        .resize(markers.len(), &mut ui.widget_id_generator())
690                });
691            }
692            if state.ids.mmap_site_icons_bgs.len() < markers.len() {
693                state.update(|state| {
694                    state
695                        .ids
696                        .mmap_site_icons_bgs
697                        .resize(markers.len(), &mut ui.widget_id_generator())
698                });
699            }
700
701            let wpos_to_rpos = |wpos: Vec2<f32>, limit: bool| {
702                // Site pos in world coordinates relative to the player
703                let rwpos = wpos - player_pos;
704                // Convert to chunk coordinates
705                let rcpos = rwpos.wpos_to_cpos();
706                // Convert to fractional coordinates relative to the worldsize
707                let rfpos = rcpos / max_zoom as f32;
708                // Convert to unrotated pixel coordinates from the player location on the map
709                // (the center)
710                // Accounting for zooming
711                let rpixpos = rfpos.map2(map_size, |e, sz| e * sz as f32 * zoom as f32);
712                let rpos = Vec2::unit_x().rotated_z(orientation.x) * rpixpos.x
713                    + Vec2::unit_y().rotated_z(orientation.x) * rpixpos.y;
714
715                if rpos
716                    .map2(map_size, |e, sz| e.abs() > sz as f32 / 2.0)
717                    .reduce_or()
718                {
719                    limit.then(|| {
720                        let clamped = rpos / rpos.map(|e| e.abs()).reduce_partial_max();
721                        clamped * map_size.map(|e| e as f32) / 2.0
722                    })
723                } else {
724                    Some(rpos)
725                }
726            };
727
728            for (i, marker) in markers.iter().enumerate() {
729                let rpos = match wpos_to_rpos(marker.wpos.map(|e| e as f32), false) {
730                    Some(rpos) => rpos,
731                    None => continue,
732                };
733                let difficulty = match &marker.kind {
734                    MarkerKind::Unknown => None,
735                    MarkerKind::Town => None,
736                    MarkerKind::ChapelSite => Some(4),
737                    MarkerKind::Terracotta => Some(5),
738                    MarkerKind::Castle => None,
739                    MarkerKind::Cave => None,
740                    MarkerKind::Tree => None,
741                    MarkerKind::Gnarling => Some(0),
742                    MarkerKind::Bridge | MarkerKind::GliderCourse => None,
743                    MarkerKind::Adlet => Some(1),
744                    MarkerKind::Sahagin => Some(2),
745                    MarkerKind::Haniwa => Some(3),
746                    MarkerKind::Cultist => Some(5),
747                    MarkerKind::Myrmidon => Some(4),
748                    MarkerKind::DwarvenMine => Some(5),
749                    MarkerKind::VampireCastle => Some(2),
750                };
751
752                Image::new(match &marker.kind {
753                    MarkerKind::Unknown => self.imgs.mmap_unknown_bg,
754                    MarkerKind::Town => self.imgs.mmap_site_town_bg,
755                    MarkerKind::ChapelSite => self.imgs.mmap_site_sea_chapel_bg,
756                    MarkerKind::Terracotta => self.imgs.mmap_site_terracotta_bg,
757                    MarkerKind::Castle => self.imgs.mmap_site_castle_bg,
758                    MarkerKind::Cave => self.imgs.mmap_site_cave_bg,
759                    MarkerKind::Tree => self.imgs.mmap_site_tree,
760                    MarkerKind::Gnarling => self.imgs.mmap_site_gnarling_bg,
761                    MarkerKind::Bridge => self.imgs.mmap_site_bridge_bg,
762                    MarkerKind::GliderCourse => self.imgs.mmap_site_glider_course_bg,
763                    MarkerKind::Adlet => self.imgs.mmap_site_adlet_bg,
764                    MarkerKind::Haniwa => self.imgs.mmap_site_haniwa_bg,
765                    MarkerKind::Cultist => self.imgs.mmap_site_cultist_bg,
766                    MarkerKind::Sahagin => self.imgs.mmap_site_sahagin_bg,
767                    MarkerKind::Myrmidon => self.imgs.mmap_site_myrmidon_bg,
768                    MarkerKind::DwarvenMine => self.imgs.mmap_site_mine_bg,
769                    MarkerKind::VampireCastle => self.imgs.mmap_site_vampire_castle_bg,
770                })
771                .x_y_position_relative_to(
772                    state.ids.map_layers[0],
773                    position::Relative::Scalar(rpos.x as f64),
774                    position::Relative::Scalar(rpos.y as f64),
775                )
776                .w_h(20.0, 20.0)
777                .color(Some(match difficulty {
778                    Some(0) => QUALITY_LOW,
779                    Some(1) => QUALITY_COMMON,
780                    Some(2) => QUALITY_MODERATE,
781                    Some(3) => QUALITY_HIGH,
782                    Some(4) => QUALITY_EPIC,
783                    Some(5) => QUALITY_DEBUG,
784                    _ => Color::Rgba(1.0, 1.0, 1.0, 0.0),
785                }))
786                .set(state.ids.mmap_site_icons_bgs[i], ui);
787                Image::new(match &marker.kind {
788                    MarkerKind::Unknown => self.imgs.mmap_unknown,
789                    MarkerKind::Town => self.imgs.mmap_site_town,
790                    MarkerKind::ChapelSite => self.imgs.mmap_site_sea_chapel,
791                    MarkerKind::Terracotta => self.imgs.mmap_site_terracotta,
792                    MarkerKind::Castle => self.imgs.mmap_site_castle,
793                    MarkerKind::Cave => self.imgs.mmap_site_cave,
794                    MarkerKind::Tree => self.imgs.mmap_site_tree,
795                    MarkerKind::Gnarling => self.imgs.mmap_site_gnarling,
796                    MarkerKind::Bridge => self.imgs.mmap_site_bridge,
797                    MarkerKind::GliderCourse => self.imgs.mmap_site_glider_course,
798                    MarkerKind::Adlet => self.imgs.mmap_site_adlet,
799                    MarkerKind::Haniwa => self.imgs.mmap_site_haniwa,
800                    MarkerKind::Cultist => self.imgs.mmap_site_cultist,
801                    MarkerKind::Sahagin => self.imgs.mmap_site_sahagin,
802                    MarkerKind::Myrmidon => self.imgs.mmap_site_myrmidon,
803                    MarkerKind::DwarvenMine => self.imgs.mmap_site_mine,
804                    MarkerKind::VampireCastle => self.imgs.mmap_site_vampire_castle,
805                })
806                .middle_of(state.ids.mmap_site_icons_bgs[i])
807                .w_h(20.0, 20.0)
808                .color(Some(UI_HIGHLIGHT_0))
809                .set(state.ids.mmap_site_icons[i], ui);
810            }
811
812            // Group member indicators
813            let client_state = self.client.state();
814            let member_pos = client_state.ecs().read_storage::<comp::Pos>();
815            let group_members = self
816                .client
817                .group_members()
818                .iter()
819                .filter_map(|(u, r)| match r {
820                    Role::Member => Some(u),
821                    Role::Pet => None,
822                })
823                .collect::<Vec<_>>();
824            let group_size = group_members.len();
825            //let in_group = !group_members.is_empty();
826            let id_maps = client_state
827                .ecs()
828                .read_resource::<common_net::sync::IdMaps>();
829            if state.ids.member_indicators.len() < group_size {
830                state.update(|s| {
831                    s.ids
832                        .member_indicators
833                        .resize(group_size, &mut ui.widget_id_generator())
834                })
835            };
836            for (i, &uid) in group_members.iter().copied().enumerate() {
837                let entity = id_maps.uid_entity(uid);
838                let member_pos = entity.and_then(|entity| member_pos.get(entity));
839
840                if let Some(member_pos) = member_pos {
841                    let rpos = match wpos_to_rpos(member_pos.0.xy(), false) {
842                        Some(rpos) => rpos,
843                        None => continue,
844                    };
845
846                    let factor = 1.2;
847                    let z_comparison = (member_pos.0.z - player_pos.z) as i32;
848                    Button::image(match z_comparison {
849                        10..=i32::MAX => self.imgs.indicator_group_up,
850                        i32::MIN..=-10 => self.imgs.indicator_group_down,
851                        _ => self.imgs.indicator_group,
852                    })
853                    .x_y_position_relative_to(
854                        state.ids.map_layers[0],
855                        position::Relative::Scalar(rpos.x as f64),
856                        position::Relative::Scalar(rpos.y as f64),
857                    )
858                    .w_h(16.0 * factor, 16.0 * factor)
859                    .image_color(Color::Rgba(1.0, 1.0, 1.0, 1.0))
860                    .set(state.ids.member_indicators[i], ui);
861                }
862            }
863
864            // Group location markers
865            if state.ids.location_marker_group.len() < self.location_markers.group.len() {
866                state.update(|s| {
867                    s.ids.location_marker_group.resize(
868                        self.location_markers.group.len(),
869                        &mut ui.widget_id_generator(),
870                    )
871                })
872            };
873            for (i, (&uid, &rpos)) in self.location_markers.group.iter().enumerate() {
874                let lm = rpos.as_();
875                if let Some(rpos) = wpos_to_rpos(lm, true) {
876                    let (image_id, factor) = match self.client.group_info().map(|info| info.1) {
877                        Some(leader) if leader == uid => {
878                            (self.imgs.location_marker_group_leader, 1.2)
879                        },
880                        _ => (self.imgs.location_marker_group, 1.0),
881                    };
882
883                    Image::new(image_id)
884                        .x_y_position_relative_to(
885                            state.ids.map_layers[0],
886                            position::Relative::Scalar(rpos.x as f64),
887                            position::Relative::Scalar(rpos.y as f64 + 8.0 * factor),
888                        )
889                        .w_h(16.0 * factor, 16.0 * factor)
890                        .parent(ui.window)
891                        .set(state.ids.location_marker_group[i], ui)
892                }
893            }
894
895            // Location marker
896            if let Some(rpos) = self
897                .location_markers
898                .owned
899                .and_then(|lm| wpos_to_rpos(lm.as_(), true))
900            {
901                let factor = 1.2;
902
903                Image::new(self.imgs.location_marker)
904                    .x_y_position_relative_to(
905                        state.ids.map_layers[0],
906                        position::Relative::Scalar(rpos.x as f64),
907                        position::Relative::Scalar(rpos.y as f64 + 8.0 * factor),
908                    )
909                    .w_h(16.0 * factor, 16.0 * factor)
910                    .parent(ui.window)
911                    .set(state.ids.location_marker, ui)
912            }
913            // Indicator
914            let ind_scale = 0.4;
915            let ind_rotation = if is_facing_north {
916                self.rot_imgs.indicator_mmap_small.target_north
917            } else {
918                self.rot_imgs.indicator_mmap_small.none
919            };
920            Image::new(ind_rotation)
921                .middle_of(state.ids.map_layers[0])
922                .w_h(32.0 * ind_scale, 37.0 * ind_scale)
923                .color(Some(UI_HIGHLIGHT_0))
924                .parent(ui.window)
925                .set(state.ids.indicator, ui);
926
927            // Compass directions
928            let dirs = [
929                (Vec2::new(0.0, 1.0), state.ids.mmap_north, "N", true),
930                (Vec2::new(1.0, 0.0), state.ids.mmap_east, "E", false),
931                (Vec2::new(0.0, -1.0), state.ids.mmap_south, "S", false),
932                (Vec2::new(-1.0, 0.0), state.ids.mmap_west, "W", false),
933            ];
934            for (dir, id, name, bold) in dirs.iter() {
935                let cardinal_dir = Vec2::unit_x().rotated_z(orientation.x as f64) * dir.x
936                    + Vec2::unit_y().rotated_z(orientation.x as f64) * dir.y;
937                let clamped = cardinal_dir / cardinal_dir.map(|e| e.abs()).reduce_partial_max();
938                let pos = clamped * (map_size / 2.0 - 10.0);
939                Text::new(name)
940                    .x_y_position_relative_to(
941                        state.ids.map_layers[0],
942                        position::Relative::Scalar(pos.x),
943                        position::Relative::Scalar(pos.y),
944                    )
945                    .font_size(self.fonts.cyri.scale(18))
946                    .font_id(self.fonts.cyri.conrod_id)
947                    .color(if *bold {
948                        Color::Rgba(0.75, 0.0, 0.0, 1.0)
949                    } else {
950                        TEXT_COLOR
951                    })
952                    .parent(ui.window)
953                    .set(*id, ui);
954            }
955        } else {
956            Image::new(self.imgs.mmap_frame_closed)
957                .w_h(174.0 * SCALE, 18.0 * SCALE)
958                .color(Some(UI_MAIN))
959                .top_right_with_margins_on(ui.window, 0.0, 5.0)
960                .set(state.ids.mmap_frame, ui);
961        }
962
963        if Button::image(if show_minimap {
964            self.imgs.mmap_open
965        } else {
966            self.imgs.mmap_closed
967        })
968        .w_h(18.0 * SCALE, 18.0 * SCALE)
969        .hover_image(if show_minimap {
970            self.imgs.mmap_open_hover
971        } else {
972            self.imgs.mmap_closed_hover
973        })
974        .press_image(if show_minimap {
975            self.imgs.mmap_open_press
976        } else {
977            self.imgs.mmap_closed_press
978        })
979        .top_right_with_margins_on(state.ids.mmap_frame, 0.0, 0.0)
980        .image_color(UI_HIGHLIGHT_0)
981        .set(state.ids.mmap_button, ui)
982        .was_clicked()
983        {
984            events.push(Event::SettingsChange(MinimapShow(!show_minimap)));
985        }
986
987        // TODO: Subregion name display
988
989        // Title
990
991        match self.client.current_chunk() {
992            Some(chunk) => {
993                // Count characters in the name to avoid clipping with the name display
994                if let Some(name) = chunk.meta().name() {
995                    let name_len = name.chars().count();
996                    Text::new(name)
997                        .mid_top_with_margin_on(state.ids.mmap_frame, match name_len {
998                            15..=30 => 4.0,
999                            _ => 2.0,
1000                        })
1001                        .font_size(self.fonts.cyri.scale(match name_len {
1002                            0..=15 => 18,
1003                            16..=30 => 14,
1004                            _ => 14,
1005                        }))
1006                        .font_id(self.fonts.cyri.conrod_id)
1007                        .color(TEXT_COLOR)
1008                        .set(state.ids.mmap_location, ui)
1009                }
1010            },
1011            None => Text::new(" ")
1012                .mid_top_with_margin_on(state.ids.mmap_frame, 0.0)
1013                .font_size(self.fonts.cyri.scale(18))
1014                .color(TEXT_COLOR)
1015                .set(state.ids.mmap_location, ui),
1016        }
1017
1018        events
1019    }
1020}