Create World: the preparing screen and the experimental-features warning

Vanilla reads the world generation data behind a loading screen before
the Create New World screen opens, again after its Data Packs or
Experiments screen applied a selection, and warns before applying a
selection that asks for experimental features. Horizon showed none of
that.

- `createWorld.preparing` (`GenericMessageScreen`: the menu background
  and the centred message on a black box with a 1-pixel border and 12
  pixels of padding, no progress): Create New World reads
  `vanillagen::shared_data` on a worker while the title shows it, and
  opens the create screen when the read is done; the Data Packs screen's
  Done and the Experiments screen's Done re-run the read the same way
  (`applyNewPackConfig`).
- `ConfirmExperimentalFeaturesScreen`: when the Data Packs screen's Done
  picks a feature pack, the warning comes up first — the title, the red
  message wrapped to 310 and centred, a 100-wide Details button, Proceed
  and Back in the footer, the whole block centred, Escape declining. Its
  `DetailsScreen` lists the picked packs bold with the flags they ask
  for (`selectWorld.experimental.details.entry`). Proceed applies the
  selection, Back reopens the Data Packs screen on the selection it was
  opened with (`tryApplyNewDataPacks`'s `onAbort`).

Stacked on #4619, whose Experiments screen and feature-pack plumbing the
confirm screen builds on.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
This commit is contained in:
masa-masa-0314andGLM 5.3 committed 2026-10-31 16:16:16 +08:00
1 parent 228b7d3491
commit 072cc61277
6 files changed
+440 -9

No files matched your search

+43
View File
@@ -2058,6 +2058,9 @@ struct Game<'a> {
lectern_pulses: Vec<((i32, i32, i32), i32)>,
/// LevelLoadingScreen.smoothedProgress while the player is not placed yet.
loading_progress: f32,
/// `CreateWorldScreen.openCreateWorldScreen`'s data read, on a worker,
/// while the title says `createWorld.preparing`.
creation_job: Option<std::thread::JoinHandle<()>>,
/// When `loading_progress` last eased (the server thread's load, 50 ms a step).
loading_progress_at: Option<std::time::Instant>,
/// The world spawn search's origin chunk and fallback height, once known.
@@ -15931,6 +15934,7 @@ impl<'a> Game<'a> {
book_last_click: None,
lectern_pulses: Vec::new(),
loading_progress: 0.0,
creation_job: None,
loading_progress_at: None,
spawn_origin: None,
spawn_spiral: Default::default(),
@@ -16327,6 +16331,7 @@ impl Game<'_> {
fn resume_world_entry(&mut self) -> Result<()> {
self.poll_integrated_start()?;
self.poll_world_rest()?;
self.poll_world_creation();
if let Some(loader) = &self.world_loader {
if !loader.ready() {
return Ok(());
@@ -16382,6 +16387,44 @@ impl Game<'_> {
Ok(())
}
/// `CreateWorldScreen.openCreateWorldScreen`: read the world generation
/// data the create screen describes, on a worker, then open the screen
/// (`title.preparing`).
fn poll_world_creation(&mut self) {
let Some(title) = self.title.as_ref() else {
return;
};
if !title.preparing() {
return;
}
if self.creation_job.is_none() {
let dir = self.options.resources.join("worldgen");
let registry = self.pack.assets.registry;
self.creation_job = std::thread::Builder::new()
.name("world-creation".into())
.spawn(move || {
if let Err(e) = horizon_client::vanillagen::shared_data(&dir, registry) {
log::warn!("Horizon: preparing world creation failed: {e}");
}
})
.ok();
return;
}
if !self
.creation_job
.as_ref()
.is_none_or(std::thread::JoinHandle::is_finished)
{
return;
}
if let Some(job) = self.creation_job.take() {
let _ = job.join();
}
if let Some(title) = self.title.as_mut() {
title.finish_preparing();
}
}
fn poll_world_rest(&mut self) -> Result<()> {
if let Some(loader) = &self.rest_loader
&& loader.ready()?
+2 -1
View File
@@ -302,8 +302,9 @@ impl Game<'_> {
super::packs::open_folder(&dir);
}
Event::DataPacks(ids) => {
let resources = self.vanilla_root();
if let Some(title) = &mut self.title {
title.set_data_packs(ids);
title.offer_data_packs(&resources, ids);
}
}
Event::OpenDataPackFolder(dir) => {
@@ -420,8 +420,9 @@ impl Loading {
packs::open_folder(&dir);
}
Event::DataPacks(ids) => {
let resources = self.vanilla_root.clone();
if let Some(title) = &mut self.menu.title {
title.set_data_packs(ids);
title.offer_data_packs(&resources, ids);
}
}
Event::OpenDataPackFolder(dir) => {
+150 -3
View File
@@ -15,6 +15,7 @@ use std::{
sync::mpsc,
};
mod confirm_experiments;
mod create_world;
mod experiments;
mod flat;
@@ -435,6 +436,13 @@ pub struct Title {
pub onboarding_finished: bool,
/// CreateWorldScreen's Experiments screen, over Create New World.
experiments: RefCell<Option<experiments::ExperimentsScreen>>,
/// ConfirmExperimentalFeaturesScreen, over Create New World.
confirm_experiments: RefCell<Option<confirm_experiments::ConfirmExperimentsScreen>>,
/// The world creation data is being read: the screen says
/// `createWorld.preparing` until it is done.
preparing: bool,
/// The confirm screen's Back: the Data Packs screen reopens.
reopen_data_packs: bool,
}
pub(crate) struct Button {
x: f32,
@@ -669,6 +677,9 @@ impl Title {
rules: RefCell::new(None),
onboarding_finished: false,
experiments: RefCell::new(None),
confirm_experiments: RefCell::new(None),
preparing: false,
reopen_data_packs: false,
}
}
/// Vanilla bypasses the selector when there are no saves.
@@ -805,6 +816,19 @@ impl Title {
};
}
}
/// `createWorld.preparing` is up: the world creation data is being read.
pub fn preparing(&self) -> bool {
self.preparing
}
/// The read finished: the flag goes, and the create screen opens — a
/// fresh one entering from the world list, the one the player already
/// filled in after the data packs changed (`openCreateWorldScreen`).
pub fn finish_preparing(&mut self) {
self.preparing = false;
if !matches!(self.mode, Mode::Create { .. }) {
self.mode = Self::new_world();
}
}
/// The Data Packs screen's Done (`applyNewPackConfig`): its selection for
/// the new world.
/// `PackSelectionScreen.onFilesDrop`: a pack dropped on the window joins
@@ -824,6 +848,41 @@ impl Title {
log::warn!("Horizon: {} is not a data pack", path.display());
}
}
/// The world's data pack selection, for the game to reoffer.
pub fn data_packs(&self) -> Vec<String> {
match &self.mode {
Mode::Create { options, .. } => options.data_packs.clone(),
_ => Vec::new(),
}
}
/// The new world's pack folder, for the Data Packs screen to reopen.
pub fn pack_dir(&self) -> Option<PathBuf> {
match &self.mode {
Mode::Create { options, .. } => options.pack_dir.clone(),
_ => None,
}
}
/// The Data Packs screen's Done (`tryApplyNewDataPacks`): a selection
/// with feature packs asks for experimental features, so the confirm
/// screen comes up first; anything else applies at once and the data
/// is read again.
pub fn offer_data_packs(&mut self, resources: &std::path::Path, ids: Vec<String>) {
let experimental = crate::datapack::feature_packs(resources)
.into_iter()
.any(|p| ids.contains(&p.id));
if experimental {
*self.confirm_experiments.get_mut() = Some(
confirm_experiments::ConfirmExperimentsScreen::new(resources, &ids),
);
} else {
self.set_data_packs(ids);
self.preparing = true;
}
}
/// Whether the confirm screen declined: the Data Packs screen reopens.
pub fn take_reopen_data_packs(&mut self) -> bool {
std::mem::take(&mut self.reopen_data_packs)
}
pub fn set_data_packs(&mut self, ids: Vec<String>) {
if let Mode::Create { options, .. } = &mut self.mode {
options.data_packs = ids;
@@ -1879,6 +1938,27 @@ impl Title {
if let Some(rules) = self.rules.borrow_mut().as_mut() {
return rules.draw(batch, atlas, cursor);
}
if self.preparing {
// `CreateWorldScreen.openCreateWorldScreen`'s queued
// `GenericMessageScreen`: the menu background and the message
// alone, centred, no progress.
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let text =
crate::lang::get_or("createWorld.preparing", "Preparing for world creation...");
let tw = atlas.font.width_text(&Text::literal(text.clone()));
let (tx, ty) = (w / 2.0 - tw / 2.0, h / 2.0 - 4.5);
// `FocusableTextWidget.builder(title, font, 12)`: 12 pixels of
// padding around the text, on a black box with a 1-pixel border.
let (bx, by) = (tx - 12.0, ty - 12.0);
let (bw, bh) = (tw + 24.0, 9.0 + 24.0);
batch.fill(atlas, bx - 1.0, by - 1.0, bw + 2.0, bh + 2.0, 0xFFD0D0D0);
batch.fill(atlas, bx, by, bw, bh, 0xFF000000);
batch.text(atlas, &text, tx, ty, u32::MAX, false);
return;
}
if let Some(screen) = self.confirm_experiments.borrow().as_ref() {
return screen.draw(batch, atlas, cursor);
}
if let Some(screen) = self.experiments.borrow().as_ref() {
return screen.draw(batch, atlas, cursor);
}
@@ -2663,7 +2743,11 @@ impl Title {
pub fn click(&mut self, x: f32, y: f32, size: (f32, f32)) -> (Action, bool) {
self.remember_direct_address();
// The game rules take the press in `press_rules`, which has the atlas.
if self.rules.get_mut().is_some() || self.experiments.get_mut().is_some() {
if self.rules.get_mut().is_some()
|| self.experiments.get_mut().is_some()
|| self.confirm_experiments.get_mut().is_some()
|| self.preparing
{
return (Action::None, false);
}
let (w, h) = (size.0.ceil(), size.1.ceil());
@@ -2837,7 +2921,10 @@ impl Title {
}
(Mode::Select { .. }, Some(11)) => {
self.create_return_to_main = false;
self.mode = Self::new_world();
// `CreateWorldScreen.openCreateWorldScreen`: the world
// generation data is read first, behind a loading screen;
// the game opens the create screen after (`preparing`).
self.preparing = true;
Action::None
}
(Mode::Select { worlds, selected }, Some(id @ 13..=15)) => {
@@ -3296,6 +3383,57 @@ impl Title {
/// it lands on a button, for its sound.
pub fn press_rules(&mut self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool {
let size = (size.0.ceil(), size.1.ceil());
if self.confirm_experiments.borrow().is_some() {
let click = self
.confirm_experiments
.borrow()
.as_ref()
.and_then(|s| s.click(atlas, cursor, size));
match click {
Some(confirm_experiments::Click::Details) => {
self.confirm_experiments
.borrow_mut()
.as_mut()
.unwrap()
.details = true;
return true;
}
Some(confirm_experiments::Click::Proceed) => {
let pending = self
.confirm_experiments
.borrow_mut()
.take()
.map(|s| s.pending);
// `tryApplyNewDataPacks` accepted: the packs apply, and
// their data is read again (`applyNewPackConfig`'s load
// screen).
if let Some(pending) = pending {
self.set_data_packs(pending);
self.preparing = true;
}
return true;
}
Some(confirm_experiments::Click::Back) => {
let screen = self.confirm_experiments.borrow();
let in_details = screen.as_ref().is_some_and(|s| s.details);
drop(screen);
if in_details {
self.confirm_experiments
.borrow_mut()
.as_mut()
.unwrap()
.details = false;
} else {
// `onAbort`: the confirm declines, the Data Packs
// screen reopens (the game side holds the folder).
self.confirm_experiments.borrow_mut().take();
self.reopen_data_packs = true;
}
return true;
}
None => return false,
}
}
if self.experiments.borrow().is_some() {
let click = self
.experiments
@@ -3323,8 +3461,13 @@ impl Title {
}
return true;
}
Some(experiments::Click::Outcome(_)) => {
Some(experiments::Click::Outcome(outcome)) => {
*self.experiments.get_mut() = None;
// `applyNewPackConfig`'s load screen: the data is read
// again for the packs the screen picked (Done only).
if outcome == experiments::Outcome::Done {
self.preparing = true;
}
return true;
}
None => return false,
@@ -3398,6 +3541,10 @@ impl Title {
}
}
pub fn escape(&mut self) {
if self.confirm_experiments.get_mut().take().is_some() {
self.reopen_data_packs = true;
return;
}
if self.experiments.get_mut().take().is_some() {
return;
}
@@ -0,0 +1,237 @@
//! CreateWorldScreen's `ConfirmExperimentalFeaturesScreen`: the warning that
//! the picked data packs ask for experimental features, with the packs' own
//! flags on a second screen (`DetailsScreen`).
//!
//! Vanilla centres a grid (columns 10 apart, rows 20 apart): the title, the
//! message wrapped to 310, a 100-wide Details button, then Proceed and Back.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch},
text::{Style, Text, named_color},
ui,
};
/// `ConfirmExperimentalFeaturesScreen.COLUMN_SPACING`.
const COLUMN_SPACING: f32 = 10.0;
/// The footer buttons' width (`Button.defaults`).
const BUTTON: f32 = 150.0;
/// `DETAILS_BUTTON_WIDTH`.
const DETAILS: f32 = 100.0;
/// `MultiLineTextWidget.setMaxWidth(310)`.
const WARNING: f32 = 310.0;
/// One button row plus the grid's 20-pixel row spacing, twice.
const ROW: f32 = 40.0;
/// A press on the screen.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Click {
/// The Details button: the packs' flags.
Details,
/// `gui.proceed`: apply the pending selection.
Proceed,
/// `gui.back` or Escape: leave the selection as it was.
Back,
}
/// One enabled feature pack, as `DetailsScreen`'s list shows it.
pub struct Entry {
/// The pack's name, bold.
pub title: Text,
/// `selectWorld.experimental.details.entry`'s argument: the flags.
pub flags: String,
}
/// The screen; `details` shows `DetailsScreen` instead.
pub struct ConfirmExperimentsScreen {
pub entries: Vec<Entry>,
/// The selection to apply on Proceed.
pub pending: Vec<String>,
pub details: bool,
in_world: bool,
}
impl ConfirmExperimentsScreen {
/// `ConfirmExperimentalFeaturesScreen::new`: the picked selection's
/// feature packs, each with the flags it asks for.
pub fn new(resources: &std::path::Path, selected: &[String]) -> Self {
let entries = crate::datapack::feature_packs(resources)
.into_iter()
.filter(|p| selected.contains(&p.id))
.filter(|p| !p.features().is_empty())
.map(|p| {
let name = format!("dataPack.{}.name", p.id);
let title = (if crate::lang::has(&name) {
Text::translate(&name, &[])
} else {
Text::literal(p.id.clone())
})
.with_style(&Style {
bold: Some(true),
..Style::default()
});
let flags = p
.features()
.iter()
.map(|f| f.trim_start_matches("minecraft:").to_owned())
.collect::<Vec<_>>()
.join(", ");
Entry { title, flags }
})
.collect();
Self {
entries,
pending: selected.to_vec(),
details: false,
in_world: false,
}
}
/// The message's lines (`MultiLineTextWidget` at 310; the text's own
/// line breaks kept).
fn message_lines(font: &Font) -> Vec<Text> {
crate::lang::get_or(
"selectWorld.experimental.message",
"Be careful!\nThis configuration requires features that are still under development. \
Your world might crash, break, or not work with future updates.",
)
.split('\n')
.flat_map(|line| {
font.split_lines(&Text::literal(line.to_owned()), WARNING)
.into_iter()
.map(|(l, _)| l)
})
.collect()
}
/// The grid's top: the whole block centred vertically
/// (`FrameLayout.alignInRectangle`).
fn block_top(message: usize, h: f32) -> f32 {
let rows = 9.0 + message as f32 * 9.0 + ROW + ROW;
((h - rows) / 2.0).max(0.0)
}
/// The Details button's rect (centred, under the message).
fn details_rect(message: usize, w: f32, h: f32) -> (f32, f32) {
(
((w - DETAILS) / 2.0).trunc(),
Self::block_top(message, h) + 9.0 + ROW,
)
}
/// A press: Details, Proceed (`gui.proceed`) or Back (`gui.back`).
pub fn click(&self, atlas: &HudAtlas, (x, y): (f32, f32), size: (f32, f32)) -> Option<Click> {
let (w, h) = size;
if self.details {
let [(bx, by)] = footer_single(w, h);
return ui::hovered((x, y), bx, by, BUTTON, 20.0).then_some(Click::Back);
}
let message = Self::message_lines(&atlas.font).len();
let (dx, dy) = Self::details_rect(message, w, h);
if ui::hovered((x, y), dx, dy, DETAILS, 20.0) {
return Some(Click::Details);
}
let [(px, py), (bx, by)] = footer_pair(w, h);
if ui::hovered((x, y), px, py, BUTTON, 20.0) {
return Some(Click::Proceed);
}
ui::hovered((x, y), bx, by, BUTTON, 20.0).then_some(Click::Back)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let font = &atlas.font;
if self.details {
// `DetailsScreen`: the header's title, the packs, Back in the
// footer.
let title = Text::translate("selectWorld.experimental.details.title", &[]);
let tw = font.width_text(&title);
batch.text_styled(
atlas,
&title,
((w - tw) / 2.0).trunc(),
15.0,
u32::MAX,
true,
);
let gray = named_color("gray").unwrap_or(0xAAAAAA);
let mut y = 63.0 + 30.0;
for entry in &self.entries {
let x = (w / 2.0 - 155.0).trunc();
batch.text_styled(atlas, &entry.title, x, y, u32::MAX, true);
y += 12.0;
let line = Text::translate("selectWorld.experimental.details.entry", &[])
.append(Text::literal(entry.flags.clone()).with_color(gray));
for (l, _) in font.split_lines(&line, WARNING) {
batch.text_styled(atlas, &l, x, y, u32::MAX, true);
y += 9.0;
}
y += 10.0;
}
let [(bx, by)] = footer_single(w, h);
ui::button(
batch,
atlas,
bx,
by,
BUTTON,
20.0,
&Text::translate("gui.back", &[]),
true,
ui::hovered(cursor, bx, by, BUTTON, 20.0),
);
return;
}
let message = Self::message_lines(font);
let top = Self::block_top(message.len(), h);
// The title, the message, Details, then the two buttons: the grid's
// rows 20 apart, the whole block centred.
let title = Text::translate("selectWorld.experimental.title", &[]);
let tw = font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).trunc(), top, u32::MAX, true);
let (dx, dy) = Self::details_rect(message.len(), w, h);
let mut y = top + 9.0;
for line in &message {
batch.text_styled(
atlas,
line,
((w - WARNING) / 2.0).trunc(),
y,
u32::MAX,
true,
);
y += 9.0;
}
ui::button(
batch,
atlas,
dx,
dy,
DETAILS,
20.0,
&Text::translate("selectWorld.experimental.details", &[]),
true,
ui::hovered(cursor, dx, dy, DETAILS, 20.0),
);
let [(px, py), (bx, by)] = footer_pair(w, h);
for (fx, fy, key) in [(px, py, "gui.proceed"), (bx, by, "gui.back")] {
ui::button(
batch,
atlas,
fx,
fy,
BUTTON,
20.0,
&Text::translate(key, &[]),
true,
ui::hovered(cursor, fx, fy, BUTTON, 20.0),
);
}
}
}
/// Two 150-wide buttons `COLUMN_SPACING` apart, centred.
fn footer_pair(w: f32, h: f32) -> [(f32, f32); 2] {
let x = ((w - (BUTTON * 2.0 + COLUMN_SPACING)) / 2.0).trunc();
let y = h - 45.0;
[(x, y), (x + BUTTON + COLUMN_SPACING, y)]
}
/// One centred footer button (`DetailsScreen`'s Back).
fn footer_single(w: f32, h: f32) -> [(f32, f32); 1] {
[(((w - BUTTON) / 2.0).trunc(), h - 45.0)]
}
+6 -4
View File
@@ -569,10 +569,12 @@ fn classic_navigation_buttons_sound_even_without_a_game_action() {
(Action::None, false)
);
assert_eq!(f.click_with_sound("Create New World"), (Action::None, true));
assert_eq!(
f.title.click(240.0, 55.0, (480.0, 262.0)),
(Action::None, false)
);
// Create New World prepares the world generation first
// (`CreateWorldScreen.openCreateWorldScreen`); the game opens the
// screen once that is done.
if f.title.click(240.0, 55.0, (480.0, 262.0)).0 == Action::None && f.title.preparing() {
f.title.finish_preparing();
}
// The Game tab's game mode cycle button.
let mode = f
.title