Theme

alex/beta-minecraft-rust

Minecraft Beta 1.7.3 Rewrite

BRANCH main
main
SNAPSHOT Draw terrain GPU-driven from packed per-section buffers with GPU occlusion culling, add a render distance slider (2-128) with a quad/disk shape, cut chunk bookkeeping at high render distances, and fix a light underflow at y=0
DOWNLOAD
Download file

root / crates / client / src / gui / menu.rs

use chrono::{Local, TimeZone};

use super::{Geometry, WIDGETS, corners};
use crate::menu::{Menu, Page, TextField, contains};
use crate::settings::{Binding, Options};

pub const LOGO: i32 = crate::assets::MENU_TEXTURES;
pub const BACKGROUND: i32 = LOGO + 1;
pub const PACK_LIST: i32 = LOGO + 3;

impl Geometry {
    /// Clears the geometry for a frame and returns the GUI scale.
    pub fn begin(&mut self, extent: [u32; 2], gui_scale: u8) -> f32 {
        self.vertices.clear();
        self.crosshair = 0..0;
        self.portrait = None;
        let (size, scale) = super::scaled_size(extent, gui_scale);
        self.size = size;
        self.projection = extent.map(|e| e as f32 / scale);
        scale
    }

    pub(super) fn gradient(&mut self, rect: [f32; 4], top: u32, bottom: u32) {
        let start = self.vertices.len();
        self.fill(rect, top);
        for index in [2, 4, 5] {
            self.vertices[start + index].color = bottom;
        }
    }

    pub(super) fn background(&mut self, rect: [f32; 4], shade: u32, scroll: f32) {
        let [x, y, w, h] = rect;
        let uv = [x / 32.0, (y + scroll) / 32.0, w / 32.0, h / 32.0];
        self.quad(corners(rect), uv, BACKGROUND, shade);
    }

    pub fn menu(&mut self, menu: &Menu, options: &Options, cursor: [f32; 2]) {
        let Some(page) = menu.page else { return };
        let [w, h] = self.size;
        let center = (w / 2.0).floor();
        let quarter = (h / 4.0).floor();
        if !matches!(page, Page::Chat | Page::Sleep) {
            if menu.in_world && !matches!(page, Page::Loading | Page::Saving) {
                self.gradient([0.0, 0.0, w, h], 0xc0101010, 0xd0101010);
            } else {
                self.background([0.0, 0.0, w, h], 0xff404040, 0.0);
            }
        }
        let title = match page {
            Page::Worlds => "Select World",
            Page::Create => "Create New World",
            Page::Rename => "Rename World",
            Page::Options => "Options",
            Page::Video => "Video Settings",
            Page::Controls => "Controls",
            Page::Multiplayer => "Play Multiplayer",
            Page::Packs => "Select Texture Pack",
            Page::Pause => "Game menu",
            Page::Loading => menu.loading_title,
            Page::Saving => "Saving level",
            Page::Error => &menu.error_title,
            _ => "",
        };
        match page {
            Page::Achievements => self.achievements(menu, options, cursor),
            Page::Statistics => self.statistics(menu, cursor),
            Page::Sign => {
                self.centered("Edit sign message:", center, 40.0);
                if let Some(editor) = &menu.sign {
                    let blink = (menu.opened.elapsed().as_millis() / 300).is_multiple_of(2);
                    self.sign_editor(editor, blink);
                }
            }
            Page::Title => {
                for (x, v) in [(-137.0, 0.0), (18.0, 45.0)] {
                    self.sprite([center + x, 30.0, 155.0, 44.0], [0.0, v, 155.0, 44.0], LOGO);
                }
                let start = self.vertices.len();
                let width = self.text_width(&menu.splash);
                self.text(&menu.splash, -(width / 2.0).floor(), -8.0, 0xff00ffff, true);
                let time = server::save::now_millis().rem_euclid(1000) as f32 / 1000.0;
                let scale = (1.8 - (common::math::sin(time * std::f32::consts::TAU) * 0.1).abs())
                    * 100.0
                    / (width + 32.0);
                let (sin, cos) = (-20.0_f32).to_radians().sin_cos();
                for vertex in &mut self.vertices[start..] {
                    let [x, y] = vertex.position.map(|v| v * scale);
                    vertex.position = [center + 90.0 + x * cos - y * sin, 70.0 + x * sin + y * cos];
                }
                self.text("Minecraft Beta 1.7.3", 2.0, 2.0, 0xff505050, true);
                let copyright = "Copyright Mojang AB. Do not distribute.";
                let x = w - self.text_width(copyright) - 2.0;
                self.text(copyright, x, h - 10.0, 0xffffffff, true);
            }
            Page::Worlds | Page::Packs => self.menu_list(menu, options),
            Page::Create | Page::Rename => {
                self.text("World Name", center - 100.0, 47.0, 0xffa0a0a0, true);
                if page == Page::Create {
                    for (text, y) in [
                        (&*format!("Will be saved in: {}", menu.folder), 85.0),
                        ("Seed for the World Generator", 104.0),
                        ("Leave blank for a random seed", 140.0),
                    ] {
                        self.text(text, center - 100.0, y, 0xffa0a0a0, true);
                    }
                }
            }
            Page::Delete => {
                self.centered("Are you sure you want to delete this world?", center, 70.0);
                if let Some(world) = menu.selected_world() {
                    self.centered(
                        &format!("'{}' will be lost forever! (A long time!)", world.name),
                        center,
                        90.0,
                    );
                }
            }
            Page::Multiplayer => {
                for (text, y) in [
                    (
                        "Minecraft Multiplayer is currently not finished, but there",
                        0.0,
                    ),
                    ("is some buggy early testing going on.", 9.0),
                    ("Enter the IP of a server to connect to it:", 36.0),
                ] {
                    self.text(text, center - 140.0, quarter + y, 0xffa0a0a0, true);
                }
            }
            Page::Controls => {
                for (index, binding) in Binding::ALL.into_iter().enumerate() {
                    let x = center - 79.0 + (index % 2) as f32 * 160.0;
                    let y = (h / 6.0).floor() + (index / 2) as f32 * 24.0 + 7.0;
                    self.text(binding.label(), x, y, 0xffffffff, true);
                }
            }
            Page::Error => {
                self.wrapped_centered(&menu.message, center, h / 2.0 - 10.0, (w - 40.0).min(420.0))
            }
            Page::Loading | Page::Saving => {
                let y = h / 2.0 + if menu.remote { -10.0 } else { 4.0 };
                self.centered(&menu.message, center, y);
                if page == Page::Loading && !menu.remote {
                    let [x, y] = [center - 50.0, h / 2.0 + 16.0];
                    self.fill([x, y, 100.0, 2.0], 0xff808080);
                    self.fill([x, y, (menu.progress * 100.0).floor(), 2.0], 0xff80ff80);
                }
            }
            _ => {}
        }
        if !title.is_empty() {
            let y = match page {
                Page::Create | Page::Rename | Page::Multiplayer => quarter - 40.0,
                Page::Packs => 16.0,
                Page::Pause => 40.0,
                Page::Error => h / 2.0 - 50.0,
                Page::Loading | Page::Saving => h / 2.0 - if menu.remote { 50.0 } else { 20.0 },
                _ => 20.0,
            };
            self.centered(title, center, y);
        }
        let blink = (menu.opened.elapsed().as_millis() / 300).is_multiple_of(2);
        for (field, rect) in menu.fields.iter().zip(menu.field_rects(self.size)) {
            if let Some(rect) = rect {
                self.text_field(field, rect, blink, matches!(page, Page::Chat | Page::Sleep));
            }
        }
        for (index, button) in menu.buttons(self.size, options).iter().enumerate() {
            if button.rect[2] == 0.0 {
                continue;
            }
            let hover = contains(button.rect, cursor) || menu.keyboard_focus == Some(index);
            let [x, y, width, height] = button.rect;
            let v = if !button.enabled || button.slider.is_some() {
                46.0
            } else if hover {
                86.0
            } else {
                66.0
            };
            let half = width / 2.0;
            for (offset, u) in [(0.0, 0.0), (half, 200.0 - half)] {
                self.sprite([x + offset, y, half, height], [u, v, half, height], WIDGETS);
            }
            if let Some(value) = button.slider {
                let knob = x + (value * (width - 8.0)).floor();
                for (offset, u) in [(0.0, 0.0), (4.0, 196.0)] {
                    self.sprite([knob + offset, y, 4.0, 20.0], [u, 66.0, 4.0, 20.0], WIDGETS);
                }
            }
            let color = if !button.enabled {
                0xffa0a0a0
            } else if hover {
                0xffa0ffff
            } else {
                0xffe0e0e0
            };
            let left = self.centered_x(&button.label, x + half.floor());
            let top = y + ((height - 8.0) / 2.0).floor();
            self.text(&button.label, left, top, color, true);
        }
    }

    fn menu_list(&mut self, menu: &Menu, options: &Options) {
        let [w, h] = self.size;
        let center = (w / 2.0).floor();
        let bottom = menu.list_bottom(self.size);
        self.background([0.0, 32.0, w, bottom - 32.0], 0xff202020, menu.scroll);
        let first = (menu.scroll / 36.0) as usize;
        let end = ((menu.scroll + bottom - 32.0) / 36.0).ceil() as usize + 1;
        for index in first..end.min(menu.list_len()) {
            let y = 36.0 + index as f32 * 36.0 - menu.scroll.floor();
            let selected = if menu.page == Some(Page::Worlds) {
                menu.selected == Some(index)
            } else {
                options.texture_pack == menu.packs[index].name
            };
            if selected {
                self.fill([center - 110.0, y - 2.0, 220.0, 36.0], 0xff808080);
                self.fill([center - 109.0, y - 1.0, 218.0, 34.0], 0xff000000);
            }
            let start = self.vertices.len();
            if menu.page == Some(Page::Worlds) {
                let world = &menu.worlds[index];
                self.text(&world.name, center - 106.0, y + 1.0, 0xffffffff, true);
                let date = Local
                    .timestamp_millis_opt(world.last_played)
                    .single()
                    .map_or_else(String::new, short_date_time);
                let size = (world.size / 1024 * 100 / 1024) as f32 / 100.0;
                let details = format!("{} ({date}, {size} MB)", world.folder);
                self.text(&details, center - 106.0, y + 12.0, 0xff808080, true);
                let problem = if world.needs_conversion {
                    Some("Must be converted!")
                } else {
                    world.problem.as_deref()
                };
                if let Some(problem) = problem {
                    self.text(problem, center - 106.0, y + 22.0, 0xff808080, true);
                }
            } else {
                let pack = &menu.packs[index];
                let points = corners([center - 108.0, y, 32.0, 32.0]);
                let texture = PACK_LIST + index as i32;
                self.quad(points, [0.0, 0.0, 1.0, 1.0], texture, 0xffffffff);
                self.text(&pack.name, center - 74.0, y + 1.0, 0xffffffff, true);
                for (line, offset) in pack.description.iter().zip([12.0, 22.0]) {
                    self.text(line, center - 74.0, y + offset, 0xff808080, true);
                }
            }
            self.clip_quads(start, [0.0, 32.0, w, bottom - 32.0]);
        }
        self.background([0.0, 0.0, w, 32.0], 0xff404040, 0.0);
        self.background([0.0, bottom, w, h - bottom], 0xff404040, 0.0);
        self.gradient([0.0, 32.0, w, 4.0], 0xff000000, 0);
        self.gradient([0.0, bottom - 4.0, w, 4.0], 0, 0xff000000);
        let max = menu.max_scroll(self.size);
        if max > 0.0 {
            let height = bottom - 32.0;
            let thumb = menu.scroll_thumb(self.size);
            let y = 32.0 + menu.scroll / max * (height - thumb);
            self.fill([center + 124.0, 32.0, 6.0, height], 0xff000000);
            self.fill([center + 124.0, y, 6.0, thumb], 0xff808080);
            self.fill([center + 124.0, y, 5.0, thumb - 1.0], 0xffc0c0c0);
        }
        if menu.page == Some(Page::Packs) {
            let text = "(Place texture pack files here)";
            let x = self.centered_x(text, center - 77.0);
            self.text(text, x, h - 26.0, 0xff808080, true);
        }
    }

    fn text_field(&mut self, field: &TextField, rect: [f32; 4], blink: bool, chat: bool) {
        let [x, y, w, h] = rect;
        if chat {
            self.fill(rect, 0x80000000);
        } else {
            self.fill([x - 1.0, y - 1.0, w + 2.0, h + 2.0], 0xffa0a0a0);
            self.fill(rect, 0xff000000);
        }
        let start = self.vertices.len();
        // `GuiChat.drawScreen`: an unshadowed "> " prompt at x = 4
        let prompt = if chat { "> " } else { "" };
        let offset = (self.text_width(&field.text[..field.cursor]) - (w - 16.0)).max(0.0);
        let text_x = x + if chat { 2.0 } else { 4.0 } + self.text_width(prompt) - offset;
        let text_y = y + (h - 8.0) / 2.0;
        if chat {
            let x = text_x - self.text_width(prompt);
            self.text(prompt, x, text_y, 0xffe0e0e0, false);
        }
        let selection = field.selection();
        if field.focused && !selection.is_empty() {
            let left = text_x + self.text_width(&field.text[..selection.start]);
            let width = self.text_width(&field.text[selection]);
            self.fill([left, text_y - 1.0, width, 10.0], 0xffa04020);
        }
        self.text(&field.text, text_x, text_y, 0xffe0e0e0, !chat);
        if field.focused && blink {
            let x = text_x + self.text_width(&field.text[..field.cursor]);
            self.text("_", x, text_y, 0xffe0e0e0, !chat);
        }
        self.clip_quads(start, [x + 2.0, y, w - 4.0, h]);
    }

    pub(super) fn clip_quads(&mut self, start: usize, [x, y, w, h]: [f32; 4]) {
        for quad in self.vertices[start..].as_chunks_mut::<6>().0 {
            let first = quad[0];
            let last = quad[2];
            let width = last.position[0] - first.position[0];
            let height = last.position[1] - first.position[1];
            if width <= 0.0 || height <= 0.0 {
                continue;
            }
            for vertex in quad {
                let [px, py] = vertex.position;
                let position = [px.clamp(x, x + w), py.clamp(y, y + h)];
                vertex.uv[0] += (position[0] - px) / width * (last.uv[0] - first.uv[0]);
                vertex.uv[1] += (position[1] - py) / height * (last.uv[1] - first.uv[1]);
                vertex.position = position;
            }
        }
    }

    /// Words joined into lines narrower than `width`, ending with a possibly empty line.
    pub(super) fn wrap_words(&self, text: &str, width: f32) -> Vec<String> {
        let mut lines = vec![String::new()];
        for word in text.split_whitespace() {
            let line = lines.last_mut().unwrap();
            if !line.is_empty() && self.text_width(line) + self.text_width(word) + 4.0 > width {
                lines.push(word.into());
            } else {
                if !line.is_empty() {
                    line.push(' ');
                }
                line.push_str(word);
            }
        }
        lines
    }

    fn wrapped_centered(&mut self, text: &str, x: f32, y: f32, width: f32) {
        for (index, line) in self.wrap_words(text, width).iter().enumerate() {
            self.centered(line, x, y + index as f32 * 10.0);
        }
    }

    /// `GuiIngame.addChatMessage`: a message split into lines of at most 320 pixels.
    fn wrap_chat<'a>(&self, mut message: &'a str, lines: &mut Vec<&'a str>) {
        while self.text_width(message) > 320.0 {
            let mut end = message.chars().next().map_or(0, char::len_utf8);
            for (index, ch) in message.char_indices().skip(1) {
                if self.text_width(&message[..index + ch.len_utf8()]) > 320.0 {
                    break;
                }
                end = index + ch.len_utf8();
            }
            lines.push(&message[..end]);
            message = &message[end..];
        }
        lines.push(message);
    }

    /// The chat of `GuiIngame.renderGameOverlay`: fading after ten seconds, or the last 20
    /// lines while chat is open. Messages carry their age in ticks.
    pub fn chat(&mut self, messages: &[(String, u32)], open: bool) {
        let mut lines = Vec::new();
        for (message, age) in messages.iter().rev() {
            let mut wrapped = Vec::new();
            self.wrap_chat(message, &mut wrapped);
            lines.extend(wrapped.into_iter().rev().map(|line| (line, *age)));
            if lines.len() >= 50 {
                break;
            }
        }
        let y = self.size[1] - 48.0;
        for (index, (line, age)) in lines
            .into_iter()
            .take(if open { 20 } else { 10 })
            .enumerate()
        {
            let alpha = if open {
                255
            } else if age < 200 {
                let fade = ((1.0 - f64::from(age) / 200.0) * 10.0).clamp(0.0, 1.0);
                (255.0 * fade * fade) as u32
            } else {
                0
            };
            if alpha == 0 {
                continue;
            }
            let y = y - index as f32 * 9.0;
            self.fill([2.0, y - 1.0, 320.0, 9.0], (alpha / 2) << 24);
            self.text(line, 2.0, y, 0xffffff | alpha << 24, true);
        }
    }
}

/// `new SimpleDateFormat()`: the short date and time of the user's locale, as Java has them.
fn short_date_time(date: chrono::DateTime<Local>) -> String {
    use chrono::{Datelike, Timelike};
    let locale = ["LC_ALL", "LC_TIME", "LANG"]
        .into_iter()
        .filter_map(|key| std::env::var(key).ok())
        .find(|value| !value.is_empty())
        .unwrap_or_default();
    let (language, country) = locale
        .split(['.', '@'])
        .next()
        .unwrap_or_default()
        .split_once('_')
        .unwrap_or((&locale, ""));
    let pattern = match (language, country) {
        ("en", "GB" | "IE" | "AU" | "NZ" | "IN") | ("fr" | "pt", _) => "dd/MM/yy HH:mm",
        ("de" | "pl" | "cs" | "fi" | "nb" | "no", _) => "dd.MM.yy HH:mm",
        ("ru", _) => "dd.MM.yy H:mm",
        ("es", _) => "d/MM/yy H:mm",
        ("it", _) => "dd/MM/yy H.mm",
        ("nl", _) => "d-M-yy H:mm",
        ("sv", _) => "yyyy-MM-dd HH:mm",
        ("ja", _) => "yy/MM/dd H:mm",
        ("da", _) => "dd-MM-yy HH:mm",
        _ => "M/d/yy h:mm a",
    };
    let mut out = String::new();
    let mut chars = pattern.chars().peekable();
    while let Some(ch) = chars.next() {
        let mut count = 1;
        while chars.peek() == Some(&ch) {
            chars.next();
            count += 1;
        }
        let number = |value: u32| format!("{value:0count$}");
        match ch {
            'y' if count == 2 => out += &format!("{:02}", date.year().rem_euclid(100)),
            'y' => out += &date.year().to_string(),
            'M' => out += &number(date.month()),
            'd' => out += &number(date.day()),
            'H' => out += &number(date.hour()),
            'h' => out += &number(date.hour12().1),
            'm' => out += &number(date.minute()),
            'a' => out += if date.hour12().0 { "PM" } else { "AM" },
            other => (0..count).for_each(|_| out.push(other)),
        }
    }
    out
}