alex/beta-minecraft-rust
Minecraft Beta 1.7.3 Rewrite
BRANCH 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
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
}