Merge pull request #1734 from ludovic111/feat/server-dialog-screen
🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
This commit is contained in:
@@ -0,0 +1,794 @@
|
||||
//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
|
||||
//! decodes from a `show_dialog` packet, the values its inputs hold, and the
|
||||
//! click event an action makes of them (`Action.createAction`).
|
||||
use crate::text::Text;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `CommonDialogData` and the type's own fields.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Dialog {
|
||||
pub kind: Kind,
|
||||
pub title: Text,
|
||||
pub external_title: Option<Text>,
|
||||
pub can_close_with_escape: bool,
|
||||
pub pause: bool,
|
||||
pub after_action: AfterAction,
|
||||
pub body: Vec<Body>,
|
||||
pub inputs: Vec<Input>,
|
||||
}
|
||||
|
||||
/// The dialog types (`DialogTypes`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Kind {
|
||||
/// `NoticeDialog`: one button, "OK" when the server names none.
|
||||
Notice(Button),
|
||||
Confirmation {
|
||||
yes: Button,
|
||||
no: Button,
|
||||
},
|
||||
MultiAction {
|
||||
actions: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
},
|
||||
ServerLinks {
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogAction`: the screen after an action runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AfterAction {
|
||||
Close,
|
||||
None,
|
||||
WaitForResponse,
|
||||
}
|
||||
|
||||
/// `ActionButton`: `CommonButtonData` and what it does.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Button {
|
||||
pub label: Text,
|
||||
pub tooltip: Option<Text>,
|
||||
pub width: i32,
|
||||
pub action: Option<Action>,
|
||||
}
|
||||
|
||||
/// `Action`: a fixed click event, or one made from the inputs' values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
/// `StaticAction`: a click event as it is.
|
||||
Static(Click),
|
||||
/// `CommandTemplate`: a command with `$(key)` replaced by input values.
|
||||
RunCommand(String),
|
||||
/// `CustomAll`: a custom click event whose payload is `additions` plus
|
||||
/// every input's value under its key.
|
||||
Custom { id: String, additions: Option<Tag> },
|
||||
}
|
||||
|
||||
/// `ClickEvent`, the kinds a server may send (`isAllowedFromServer`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
OpenUrl(String),
|
||||
RunCommand(String),
|
||||
SuggestCommand(String),
|
||||
/// The dialog: an id of the `minecraft:dialog` registry or the dialog itself.
|
||||
ShowDialog(Tag),
|
||||
ChangePage(i32),
|
||||
CopyToClipboard(String),
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<Tag>,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogBody`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Body {
|
||||
/// `PlainMessage`: centred text wrapped to `width`.
|
||||
Message { contents: Text, width: i32 },
|
||||
/// `ItemBody`: the item as `ItemStackTemplate` sent it.
|
||||
Item {
|
||||
item: Tag,
|
||||
description: Option<(Text, i32)>,
|
||||
show_decorations: bool,
|
||||
show_tooltip: bool,
|
||||
width: i32,
|
||||
height: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Input`: the key its value goes by, and its control.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Input {
|
||||
pub key: String,
|
||||
pub control: Control,
|
||||
}
|
||||
|
||||
/// `InputControl`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Control {
|
||||
Text {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
initial: String,
|
||||
max_length: i32,
|
||||
/// `MultilineOptions`: the most lines and the height, when set.
|
||||
multiline: Option<(Option<i32>, Option<i32>)>,
|
||||
},
|
||||
Boolean {
|
||||
label: Text,
|
||||
initial: bool,
|
||||
on_true: String,
|
||||
on_false: String,
|
||||
},
|
||||
SingleOption {
|
||||
width: i32,
|
||||
/// Each option's id, its label and whether it is the initial one.
|
||||
options: Vec<(String, Option<Text>, bool)>,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
},
|
||||
NumberRange {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_format: String,
|
||||
start: f32,
|
||||
end: f32,
|
||||
initial: Option<f32>,
|
||||
step: Option<f32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an input holds while the dialog is open.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum InputValue {
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
/// The chosen option's index.
|
||||
Option(usize),
|
||||
/// The slider's position, 0 to 1.
|
||||
Slider(f32),
|
||||
}
|
||||
|
||||
fn text(t: &Tag) -> Text {
|
||||
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))
|
||||
}
|
||||
fn string(t: Option<&Tag>) -> Option<String> {
|
||||
match t? {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn number(t: Option<&Tag>) -> Option<f64> {
|
||||
Some(match t? {
|
||||
Tag::Byte(v) => f64::from(*v),
|
||||
Tag::Short(v) => f64::from(*v),
|
||||
Tag::Int(v) => f64::from(*v),
|
||||
Tag::Long(v) => *v as f64,
|
||||
Tag::Float(v) => f64::from(f32::from_bits(*v)),
|
||||
Tag::Double(v) => f64::from_bits(*v),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn int(t: &Tag, key: &str, default: i32) -> i32 {
|
||||
number(t.get(key)).map_or(default, |v| v as i32)
|
||||
}
|
||||
fn boolean(t: &Tag, key: &str, default: bool) -> bool {
|
||||
number(t.get(key)).map_or(default, |v| v != 0.0)
|
||||
}
|
||||
fn list(t: Option<&Tag>) -> &[Tag] {
|
||||
match t {
|
||||
Some(Tag::List(items)) => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// A registry name without its default namespace.
|
||||
fn path(id: &str) -> &str {
|
||||
id.strip_prefix("minecraft:").unwrap_or(id)
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// `ActionButton.CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
Some(Self {
|
||||
label: text(t.get("label")?),
|
||||
tooltip: t.get("tooltip").map(text),
|
||||
width: int(t, "width", 150),
|
||||
action: t.get("action").and_then(Action::parse),
|
||||
})
|
||||
}
|
||||
/// `NoticeDialog.DEFAULT_ACTION`.
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
label: Text::translate("gui.ok", &[]),
|
||||
tooltip: None,
|
||||
width: 150,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Click {
|
||||
/// `ClickEvent.CODEC`, from its `action` (a click event in a component)
|
||||
/// or `type` (an action) field.
|
||||
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
|
||||
let s = |key| string(t.get(key));
|
||||
Some(match path(kind) {
|
||||
"open_url" => Self::OpenUrl(s("url")?),
|
||||
"run_command" => Self::RunCommand(s("command")?),
|
||||
"suggest_command" => Self::SuggestCommand(s("command")?),
|
||||
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
|
||||
"change_page" => Self::ChangePage(int(t, "page", 1)),
|
||||
"copy_to_clipboard" => Self::CopyToClipboard(s("value")?),
|
||||
"custom" => Self::Custom {
|
||||
id: s("id")?,
|
||||
payload: t.get("payload").cloned(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// `Action.CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let kind = string(t.get("type"))?;
|
||||
Some(match path(&kind) {
|
||||
"dynamic/run_command" => Self::RunCommand(string(t.get("template"))?),
|
||||
"dynamic/custom" => Self::Custom {
|
||||
id: string(t.get("id"))?,
|
||||
additions: t.get("additions").cloned(),
|
||||
},
|
||||
other => Self::Static(Click::parse(t, other)?),
|
||||
})
|
||||
}
|
||||
/// `createAction`: the click event, given each input's key, control and value.
|
||||
pub fn click(&self, inputs: &[Input], values: &[InputValue]) -> Option<Click> {
|
||||
let values = || inputs.iter().zip(values);
|
||||
Some(match self {
|
||||
Self::Static(click) => click.clone(),
|
||||
Self::RunCommand(template) => Click::RunCommand(substitute(template, &|key| {
|
||||
values()
|
||||
.find(|(i, _)| i.key == key)
|
||||
.map(|(i, v)| i.control.substitution(v))
|
||||
})),
|
||||
Self::Custom { id, additions } => {
|
||||
let mut tag = match additions {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
for (input, value) in values() {
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert(input.key.as_str().into(), input.control.tag(value));
|
||||
}
|
||||
}
|
||||
Click::Custom {
|
||||
id: id.clone(),
|
||||
payload: Some(tag),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParsedTemplate.instantiate`: each `$(key)` replaced by its value, or by
|
||||
/// nothing for a key no input has.
|
||||
fn substitute(template: &str, value: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(at) = rest.find("$(") {
|
||||
let Some(end) = rest[at..].find(')') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..at]);
|
||||
out.push_str(&value(&rest[at + 2..at + end]).unwrap_or_default());
|
||||
rest = &rest[at + end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `StringTag.escapeWithoutQuotes`.
|
||||
fn escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' | '\'' | '\\' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
'\u{8}' => out.push_str("\\b"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\u{c}' => out.push_str("\\f"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c if c < ' ' => out.push_str(&format!("\\x{:02x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Control {
|
||||
/// `InputControl.MAP_CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
o.get("display").map(text),
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_format: string(t.get("label_format"))
|
||||
.unwrap_or_else(|| "options.generic_value".into()),
|
||||
start: float("start")?,
|
||||
end: float("end")?,
|
||||
initial: float("initial"),
|
||||
step: float("step"),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The value it starts with.
|
||||
pub fn initial(&self) -> InputValue {
|
||||
match self {
|
||||
Self::Text { initial, .. } => InputValue::Text(initial.clone()),
|
||||
Self::Boolean { initial, .. } => InputValue::Bool(*initial),
|
||||
Self::SingleOption { options, .. } => {
|
||||
InputValue::Option(options.iter().position(|o| o.2).unwrap_or(0))
|
||||
}
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
..
|
||||
} => InputValue::Slider(to_slider(
|
||||
initial.unwrap_or((start + end) / 2.0),
|
||||
*start,
|
||||
*end,
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
(Self::Text { .. }, InputValue::Text(s)) => escape(s),
|
||||
(
|
||||
Self::Boolean {
|
||||
on_true, on_false, ..
|
||||
},
|
||||
InputValue::Bool(b),
|
||||
) => if *b { on_true } else { on_false }.clone(),
|
||||
(Self::SingleOption { options, .. }, InputValue::Option(i)) => {
|
||||
options.get(*i).map(|o| o.0.clone()).unwrap_or_default()
|
||||
}
|
||||
(Self::NumberRange { .. }, InputValue::Slider(_)) => {
|
||||
number_string(self.scaled(value).unwrap_or(0.0))
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTag`.
|
||||
pub fn tag(&self, value: &InputValue) -> Tag {
|
||||
match (self, value) {
|
||||
(Self::Boolean { .. }, InputValue::Bool(b)) => Tag::Byte(i8::from(*b)),
|
||||
(Self::NumberRange { .. }, _) => {
|
||||
Tag::Float(self.scaled(value).unwrap_or(0.0).to_bits())
|
||||
}
|
||||
(Self::Text { .. }, InputValue::Text(s)) => Tag::String(s.as_str().into()),
|
||||
_ => Tag::String(self.substitution(value).as_str().into()),
|
||||
}
|
||||
}
|
||||
/// `RangeInfo.computeScaledValue`: a number range's value at its slider position.
|
||||
pub fn scaled(&self, value: &InputValue) -> Option<f32> {
|
||||
let (
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
step,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(slider),
|
||||
) = (self, value)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (start, end) = (*start, *end);
|
||||
let in_range = start + slider * (end - start);
|
||||
let Some(step) = step else {
|
||||
return Some(in_range);
|
||||
};
|
||||
let first = initial.unwrap_or((start + end) / 2.0);
|
||||
// Math.round: half up.
|
||||
let steps = ((in_range - first) / step + 0.5).floor() as i32;
|
||||
let result = first + steps as f32 * step;
|
||||
let position = to_slider(result, start, end);
|
||||
if (0.0..=1.0).contains(&position) {
|
||||
return Some(result);
|
||||
}
|
||||
Some(first + (steps - steps.signum()) as f32 * step)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RangeInfo.scaledValueToSlider`.
|
||||
fn to_slider(value: f32, start: f32, end: f32) -> f32 {
|
||||
if start == end {
|
||||
0.5
|
||||
} else {
|
||||
(value - start) / (end - start)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SliderImpl.valueToString`: whole numbers without a fraction, the rest as
|
||||
/// Java's `Float.toString`.
|
||||
pub fn number_string(v: f32) -> String {
|
||||
if v == (v as i32) as f32 {
|
||||
(v as i32).to_string()
|
||||
} else {
|
||||
crate::snbt::java_float(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// `DialogBody.DIALOG_BODY_CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let message = |t: &Tag| -> Option<(Text, i32)> {
|
||||
// PlainMessage.CODEC: the full form or just its contents.
|
||||
match t {
|
||||
Tag::Compound(_) if t.get("contents").is_some() => {
|
||||
Some((text(t.get("contents")?), int(t, "width", 200)))
|
||||
}
|
||||
t => Some((text(t), 200)),
|
||||
}
|
||||
};
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"plain_message" => {
|
||||
let (contents, width) = message(t)?;
|
||||
Self::Message { contents, width }
|
||||
}
|
||||
"item" => Self::Item {
|
||||
item: t.get("item")?.clone(),
|
||||
description: t.get("description").and_then(message),
|
||||
show_decorations: boolean(t, "show_decorations", true),
|
||||
show_tooltip: boolean(t, "show_tooltip", true),
|
||||
width: int(t, "width", 16),
|
||||
height: int(t, "height", 16),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
/// `Dialog.DIRECT_CODEC`; `None` for a type or field it can't read
|
||||
/// (vanilla fails the whole packet then).
|
||||
pub fn parse(t: &Tag) -> Option<Self> {
|
||||
let button = |key: &str| t.get(key).and_then(Button::parse);
|
||||
let kind = match path(&string(t.get("type"))?) {
|
||||
"notice" => Kind::Notice(match t.get("action") {
|
||||
Some(b) => Button::parse(b)?,
|
||||
None => Button::ok(),
|
||||
}),
|
||||
"confirmation" => Kind::Confirmation {
|
||||
yes: button("yes")?,
|
||||
no: button("no")?,
|
||||
},
|
||||
"multi_action" => Kind::MultiAction {
|
||||
actions: list(t.get("actions"))
|
||||
.iter()
|
||||
.map(Button::parse)
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.filter(|a| !a.is_empty())?,
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
},
|
||||
"server_links" => Kind::ServerLinks {
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
external_title: t.get("external_title").map(text),
|
||||
can_close_with_escape: boolean(t, "can_close_with_escape", true),
|
||||
pause: boolean(t, "pause", true),
|
||||
after_action: match string(t.get("after_action")).as_deref() {
|
||||
Some("none") => AfterAction::None,
|
||||
Some("wait_for_response") => AfterAction::WaitForResponse,
|
||||
_ => AfterAction::Close,
|
||||
},
|
||||
body,
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
/// `onCancel`: what Escape (or closing it) does.
|
||||
pub fn on_cancel(&self) -> Option<&Action> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => b.action.as_ref(),
|
||||
Kind::Confirmation { no, .. } => no.action.as_ref(),
|
||||
Kind::MultiAction { exit, .. }
|
||||
| Kind::ServerLinks { exit, .. }
|
||||
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
|
||||
}
|
||||
}
|
||||
/// The buttons it draws, in order: the list, then the footer's.
|
||||
pub fn buttons(&self) -> Vec<&Button> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
pub fn json(&self, values: &[InputValue]) -> Value {
|
||||
let kind = match &self.kind {
|
||||
Kind::Notice(_) => "notice",
|
||||
Kind::Confirmation { .. } => "confirmation",
|
||||
Kind::MultiAction { .. } => "multi_action",
|
||||
Kind::ServerLinks { .. } => "server_links",
|
||||
Kind::DialogList { .. } => "dialog_list",
|
||||
};
|
||||
let inputs: Vec<Value> = self
|
||||
.inputs
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(i, v)| {
|
||||
let kind = match i.control {
|
||||
Control::Text { .. } => "text",
|
||||
Control::Boolean { .. } => "boolean",
|
||||
Control::SingleOption { .. } => "single_option",
|
||||
Control::NumberRange { .. } => "number_range",
|
||||
};
|
||||
json!({"key": i.key, "type": kind, "value": i.control.substitution(v)})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"type": kind,
|
||||
"title": self.title.plain(),
|
||||
"body": self.body.iter().map(|b| match b {
|
||||
Body::Message { contents, .. } => contents.plain(),
|
||||
Body::Item { item, .. } => string(item.get("id")).unwrap_or_default(),
|
||||
}).collect::<Vec<_>>(),
|
||||
"inputs": inputs,
|
||||
"buttons": self.buttons().iter().map(|b| b.label.plain()).collect::<Vec<_>>(),
|
||||
"can_close_with_escape": self.can_close_with_escape,
|
||||
"after_action": match self.after_action {
|
||||
AfterAction::Close => "close",
|
||||
AfterAction::None => "none",
|
||||
AfterAction::WaitForResponse => "wait_for_response",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn s(v: &str) -> Tag {
|
||||
Tag::String(v.into())
|
||||
}
|
||||
|
||||
/// The register dialog AuthMe 6 sends in configuration (the shape its
|
||||
/// `show_dialog` packet has on the test bed).
|
||||
fn register() -> Tag {
|
||||
Tag::compound([
|
||||
("type", s("minecraft:multi_action")),
|
||||
("title", s("Register")),
|
||||
("can_close_with_escape", Tag::Byte(0)),
|
||||
("after_action", s("wait_for_response")),
|
||||
(
|
||||
"body",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:plain_message")),
|
||||
("contents", s("Choose a password")),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"inputs",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("type", s("minecraft:text")),
|
||||
("key", s("password")),
|
||||
("label", s("Password")),
|
||||
])]),
|
||||
),
|
||||
(
|
||||
"actions",
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("label", s("Register")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/custom")),
|
||||
("id", s("authme:prejoin-register/submit")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
Tag::compound([
|
||||
("label", s("Cancel")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/run_command")),
|
||||
("template", s("say $(password) $(missing)!")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_multi_action_dialog_with_defaults() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
assert_eq!(d.title.plain(), "Register");
|
||||
assert!(!d.can_close_with_escape);
|
||||
assert!(d.pause);
|
||||
assert_eq!(d.after_action, AfterAction::WaitForResponse);
|
||||
assert!(matches!(&d.body[..], [Body::Message { width: 200, .. }]));
|
||||
let Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns,
|
||||
} = &d.kind
|
||||
else {
|
||||
panic!("{:?}", d.kind)
|
||||
};
|
||||
assert_eq!((actions.len(), exit.is_none(), *columns), (2, true, 2));
|
||||
assert_eq!(actions[0].width, 150);
|
||||
assert!(d.on_cancel().is_none());
|
||||
let [
|
||||
Input {
|
||||
key,
|
||||
control:
|
||||
Control::Text {
|
||||
max_length: 32,
|
||||
width: 200,
|
||||
..
|
||||
},
|
||||
},
|
||||
] = &d.inputs[..]
|
||||
else {
|
||||
panic!("{:?}", d.inputs)
|
||||
};
|
||||
assert_eq!(key, "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_carry_the_input_values() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
let values = [InputValue::Text("p\"w".into())];
|
||||
let Kind::MultiAction { actions, .. } = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
let custom = actions[0]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(
|
||||
custom,
|
||||
Some(Click::Custom {
|
||||
id: "authme:prejoin-register/submit".into(),
|
||||
payload: Some(Tag::compound([("password", s("p\"w"))])),
|
||||
})
|
||||
);
|
||||
// The template escapes the text; a key no input has becomes nothing.
|
||||
let command = actions[1]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_without_an_action_has_ok() {
|
||||
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
|
||||
let Kind::Notice(b) = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(b.label, Text::translate("gui.ok", &[]));
|
||||
// Without a title, or with an unknown type, there is no dialog.
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("notice"))])).is_none());
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("x")), ("title", s("Hi"))])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_ranges_snap_to_their_step() {
|
||||
let mut range = Control::NumberRange {
|
||||
width: 200,
|
||||
label: Text::literal("n"),
|
||||
label_format: "options.generic_value".into(),
|
||||
start: 0.0,
|
||||
end: 10.0,
|
||||
initial: Some(4.0),
|
||||
step: Some(3.0),
|
||||
};
|
||||
assert_eq!(range.initial(), InputValue::Slider(0.4));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(0.62)), Some(7.0));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(10.0));
|
||||
let Control::NumberRange { step, .. } = &mut range else {
|
||||
unreachable!()
|
||||
};
|
||||
*step = Some(4.0);
|
||||
// 12 is out of range: one step less.
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(8.0));
|
||||
assert_eq!(range.substitution(&InputValue::Slider(0.0)), "0");
|
||||
assert_eq!(number_string(2.5), "2.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! `DialogScreen` and its kinds (`SimpleDialogScreen`, `MultiButtonDialogScreen`):
|
||||
//! where a server dialog's title, body and buttons go in a
|
||||
//! `HeaderAndFooterLayout` (a 33 high header, the body in a `ScrollableLayout`,
|
||||
//! the footer), and the `WaitingForResponseScreen` an answer can leave open.
|
||||
use crate::{
|
||||
dialog::{Body, Dialog, Kind},
|
||||
font::Font,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
const BAR: f32 = 33.0;
|
||||
/// `DialogScreen.WARNING_BUTTON_SPRITES`: normal, disabled, highlighted.
|
||||
pub const WARNING_SPRITES: [&str; 3] = [
|
||||
"gui/sprites/dialog/warning_button",
|
||||
"gui/sprites/dialog/warning_button_disabled",
|
||||
"gui/sprites/dialog/warning_button_highlighted",
|
||||
];
|
||||
|
||||
/// One drawn piece; `y` is from the top of the body (before it scrolls) for
|
||||
/// the body's parts, from the top of the screen for the footer's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Part {
|
||||
/// A line of text and where it starts.
|
||||
Line(Text, f32, f32),
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Layout {
|
||||
/// The title and where it is drawn.
|
||||
pub title: (f32, f32),
|
||||
pub warning: [f32; 4],
|
||||
/// The body's visible area (`ScrollableLayout`'s container): x, y, w, h.
|
||||
pub area: [f32; 4],
|
||||
/// The body's full height: more than the area's scrolls.
|
||||
pub content: f32,
|
||||
pub body: Vec<Part>,
|
||||
pub footer: Vec<Part>,
|
||||
}
|
||||
|
||||
/// A laid-out element of a `LinearLayout` before it is placed.
|
||||
struct Element {
|
||||
w: f32,
|
||||
h: f32,
|
||||
parts: Vec<Part>,
|
||||
}
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
[x + dx, y + dy, w, h]
|
||||
}
|
||||
/// `FrameLayout.alignInDimension` at 0.5: the offset that centres `inner`.
|
||||
fn centre(outer: f32, inner: f32) -> f32 {
|
||||
((outer - inner) * 0.5).trunc()
|
||||
}
|
||||
|
||||
/// `FocusableTextWidget` with the body's settings: `width` wide, the text
|
||||
/// wrapped to `width - 8` and centred, 4 of padding all round.
|
||||
fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
let lines = font.split_lines(contents, width - 8.0);
|
||||
let mid = (width / 2.0).trunc();
|
||||
let parts = lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (line, _))| {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
Part::Line(line, x, 4.0 + 9.0 * i as f32)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Element {
|
||||
w: width,
|
||||
h: 9.0 * parts.len() as f32 + 8.0,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
/// is left in a centred row under them.
|
||||
fn columns(widths: &[f32], columns: usize, first: usize) -> Element {
|
||||
let columns = columns.max(1);
|
||||
let full = widths.len() / columns * columns;
|
||||
let row_width = |ws: &[f32]| ws.iter().sum::<f32>() + 2.0 * (ws.len().max(1) - 1) as f32;
|
||||
let mut col = vec![0.0f32; columns];
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
col[i % columns] = col[i % columns].max(*w);
|
||||
}
|
||||
let last = &widths[full..];
|
||||
if !last.is_empty() {
|
||||
// GridLayout spreads a cell over every column it spans (Divisor).
|
||||
let spread = row_width(last) - 2.0 * (columns - 1) as f32;
|
||||
let (each, extra) = ((spread / columns as f32).floor(), spread % columns as f32);
|
||||
for (c, w) in col.iter_mut().enumerate() {
|
||||
*w = w.max(each + f32::from(u8::from((c as f32) < extra)));
|
||||
}
|
||||
}
|
||||
let x_of = |c: usize| col[..c].iter().sum::<f32>() + 2.0 * c as f32;
|
||||
let mut parts = Vec::new();
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
let (r, c) = (i / columns, i % columns);
|
||||
parts.push(Part::Button {
|
||||
index: first + i,
|
||||
rect: [x_of(c) + centre(col[c], *w), 22.0 * r as f32, *w, 20.0],
|
||||
});
|
||||
}
|
||||
let width = x_of(columns - 1) + col[columns - 1];
|
||||
if !last.is_empty() {
|
||||
let y = 22.0 * (full / columns) as f32;
|
||||
let mut x = centre(width, row_width(last));
|
||||
for (i, w) in last.iter().enumerate() {
|
||||
parts.push(Part::Button {
|
||||
index: first + full + i,
|
||||
rect: [x, y, *w, 20.0],
|
||||
});
|
||||
x += w + 2.0;
|
||||
}
|
||||
}
|
||||
let rows = full / columns + usize::from(!last.is_empty());
|
||||
Element {
|
||||
w: width,
|
||||
h: (22.0 * rows as f32 - 2.0).max(0.0),
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// `DialogScreen.init` and `repositionElements` on a `w` by `h` screen.
|
||||
pub fn new(dialog: &Dialog, font: &Font, (w, h): (f32, f32)) -> Self {
|
||||
// The header: the title, 10 right of it the warning button, both
|
||||
// centred together.
|
||||
let tw = font.width_text(&dialog.title);
|
||||
let (hx, hy) = (centre(w, tw + 30.0), centre(BAR, 20.0));
|
||||
let title = (hx, hy + centre(20.0, 9.0));
|
||||
let warning = [hx + tw + 10.0, hy, 20.0, 20.0];
|
||||
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
// SimpleDialogScreen: the buttons side by side, 8 apart.
|
||||
Kind::Notice(_) | Kind::Confirmation { .. } => {
|
||||
(0, (0..buttons.len()).collect::<Vec<_>>(), BAR)
|
||||
}
|
||||
};
|
||||
debug_assert!(listed + footer_buttons.len() <= buttons.len());
|
||||
|
||||
// The body: a column 10 apart, each element centred in it.
|
||||
let body_w = elements.iter().map(|e| e.w).fold(0.0, f32::max);
|
||||
let content = elements.iter().map(|e| e.h).sum::<f32>()
|
||||
+ 10.0 * elements.len().saturating_sub(1) as f32;
|
||||
// ScrollableLayout: 10 each side for the scrollbar (ReserveStrategy.BOTH),
|
||||
// no higher than what the header and footer leave.
|
||||
let area_w = body_w + 20.0;
|
||||
let area_h = content.min(h - BAR - footer_h).max(0.0);
|
||||
let area_y = (BAR + 30.0).min(h - footer_h - area_h);
|
||||
let area_x = centre(w, area_w);
|
||||
let mut body = Vec::new();
|
||||
let mut y = 0.0;
|
||||
for e in elements {
|
||||
let (x, eh) = (area_x + 10.0 + centre(body_w, e.w), e.h);
|
||||
body.extend(e.at(x, y));
|
||||
y += eh + 10.0;
|
||||
}
|
||||
|
||||
let widths: Vec<f32> = footer_buttons
|
||||
.iter()
|
||||
.map(|&i| buttons[i].width as f32)
|
||||
.collect();
|
||||
let row = widths.iter().sum::<f32>() + 8.0 * widths.len().saturating_sub(1) as f32;
|
||||
let (mut x, fy) = (centre(w, row), h - footer_h + centre(footer_h, 20.0));
|
||||
let mut footer = Vec::new();
|
||||
for (&i, bw) in footer_buttons.iter().zip(&widths) {
|
||||
footer.push(Part::Button {
|
||||
index: i,
|
||||
rect: [x, fy, *bw, 20.0],
|
||||
});
|
||||
x += bw + 8.0;
|
||||
}
|
||||
Self {
|
||||
title,
|
||||
warning: Self::in_bounds(warning, w, h),
|
||||
area: [area_x, area_y, area_w, area_h],
|
||||
content,
|
||||
body,
|
||||
footer,
|
||||
}
|
||||
}
|
||||
/// `makeSureWarningButtonIsInBounds`.
|
||||
fn in_bounds(r: [f32; 4], w: f32, h: f32) -> [f32; 4] {
|
||||
if r[0] < 0.0 || r[1] < 0.0 || r[0] > w - 20.0 || r[1] > h - 20.0 {
|
||||
[(w - 40.0).max(0.0), 5.0f32.min(h), 20.0, 20.0]
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
/// Every part where it is drawn with the body scrolled by `scroll`, and
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `WaitingForResponseScreen`: its Back button's label after `ticks`, and
|
||||
/// whether it shows and works (it shows after a second, works after five).
|
||||
pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
|
||||
let seconds = (ticks / 20).min(5);
|
||||
let label = match seconds {
|
||||
0 => Text::literal(""),
|
||||
5 => Text::translate("gui.back", &[]),
|
||||
s => Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal((5 - s).to_string())],
|
||||
),
|
||||
};
|
||||
(label, seconds >= 1, seconds == 5)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
let two = columns(&[150.0, 150.0], 2, 0);
|
||||
assert_eq!((two.w, two.h), (302.0, 20.0));
|
||||
// Three in two columns: the third alone, centred under them.
|
||||
let three = columns(&[150.0, 150.0, 150.0], 2, 0);
|
||||
assert_eq!((three.w, three.h), (302.0, 42.0));
|
||||
assert_eq!(
|
||||
three.parts[2],
|
||||
Part::Button {
|
||||
index: 2,
|
||||
rect: [76.0, 22.0, 150.0, 20.0]
|
||||
}
|
||||
);
|
||||
// One in two columns spreads over both.
|
||||
let one = columns(&[100.0], 2, 0);
|
||||
assert_eq!(one.w, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_waiting_screens_button_counts_down() {
|
||||
assert!(!waiting_button(0).1);
|
||||
assert_eq!(
|
||||
waiting_button(20).0,
|
||||
Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal("4")]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
waiting_button(100),
|
||||
(Text::translate("gui.back", &[]), true, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ mod debug_keys;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
@@ -1145,6 +1146,7 @@ struct Game<'a> {
|
||||
pending_pack_reload: bool,
|
||||
/// A server's resource packs (game/server_packs.rs).
|
||||
server_packs: server_packs::ServerPackState,
|
||||
dialogs: dialogs::DialogState,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -4841,11 +4843,14 @@ impl Game<'_> {
|
||||
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
// The pack prompt replaces the connecting screen (setScreen), but
|
||||
// in game it opens over the world.
|
||||
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
|
||||
let mut batch = if (self.server_packs.prompt.is_some() || self.dialog_open())
|
||||
&& self.player.is_none()
|
||||
{
|
||||
HudBatch::new(size)
|
||||
} else {
|
||||
self.hud_layers(size)
|
||||
};
|
||||
self.draw_dialog(&mut batch);
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
// Gui.extractRenderState: the debug overlay comes after the screen
|
||||
// and the toasts, so it is over everything, menus included.
|
||||
@@ -10435,6 +10440,7 @@ impl Game<'_> {
|
||||
|| (self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& (self.screen.blurs_background(self.pause_menu)
|
||||
|| self.dialog_open()
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())));
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
(blurred && radius >= 1.0).then_some(radius)
|
||||
@@ -11137,7 +11143,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
}) * wheel_sensitivity
|
||||
};
|
||||
let d = scaled(raw_y) as f32;
|
||||
if let Some(title) = &mut self.title
|
||||
if self.dialog_open() {
|
||||
self.dialog_scroll(d);
|
||||
} else if let Some(title) = &mut self.title
|
||||
&& self.screen != Screen::Win
|
||||
{
|
||||
let size = self
|
||||
@@ -11207,6 +11215,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.pack_prompt_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.dialog_open() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.dialog_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -11421,6 +11434,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.answer_pack_prompt(false)?;
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.dialog_open() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
self.dialog_key(key)?
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.screen == Screen::Chat && self.title.is_none() =>
|
||||
{
|
||||
@@ -11902,6 +11922,7 @@ impl<'a> Game<'a> {
|
||||
world_loader: None,
|
||||
pending_pack_reload: false,
|
||||
server_packs: Default::default(),
|
||||
dialogs: Default::default(),
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
|
||||
@@ -390,6 +390,9 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn screen_label(&self) -> &'static str {
|
||||
if self.dialog_open() {
|
||||
return "dialog";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -485,6 +488,7 @@ impl Game<'_> {
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
|
||||
@@ -241,6 +241,13 @@ impl Game<'_> {
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
// A server's dialog takes the keys, in configuration too.
|
||||
if self.dialog_open() {
|
||||
if arg(2)? != "up" {
|
||||
self.dialog_key(key)?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
//! A server's dialogs bound to the game (`ClientCommonPacketListenerImpl.
|
||||
//! showDialog` and `clearDialog`, `DialogScreen`, its `WarningScreen` and the
|
||||
//! `WaitingForResponseScreen`): the screen over whatever is open (the
|
||||
//! connecting screen in configuration), its inputs, and the actions its
|
||||
//! buttons send back.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Click, Dialog, InputValue},
|
||||
dialog_screen::{Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::time::Instant;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// An open `DialogScreen`.
|
||||
struct Open {
|
||||
dialog: Dialog,
|
||||
values: Vec<InputValue>,
|
||||
scroll: ui::Scroll,
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DialogState {
|
||||
open: Option<Open>,
|
||||
/// `WaitingForResponseScreen`, since when.
|
||||
waiting: Option<Instant>,
|
||||
/// `DialogScreen.WarningScreen`, over the dialog.
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
fn warning_texts() -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
t("menu.custom_screen_info.title"),
|
||||
t("menu.custom_screen_info.contents"),
|
||||
[t("menu.disconnect"), t("gui.back")],
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a dialog screen takes the input.
|
||||
pub(super) fn dialog_open(&self) -> bool {
|
||||
let d = &self.dialogs;
|
||||
d.open.is_some() || d.waiting.is_some() || d.warning
|
||||
}
|
||||
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
pub(super) fn show_dialog(&mut self, tag: &Tag) {
|
||||
let Some(dialog) = Dialog::parse(tag) else {
|
||||
log::warn!("Horizon: failed to show dialog for data {tag:?}");
|
||||
return;
|
||||
};
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
self.dialogs.open = Some(Open {
|
||||
dialog,
|
||||
values,
|
||||
scroll: ui::Scroll::default(),
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
}
|
||||
|
||||
/// `clearDialog`: the dialog closes (under the warning screen, Back then
|
||||
/// returns to what was open before it).
|
||||
pub(super) fn clear_dialog(&mut self) {
|
||||
if self.dialogs.open.take().is_some() && !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dialog screen goes (a disconnect, or the level loading screen).
|
||||
pub(super) fn close_dialogs(&mut self) {
|
||||
self.dialogs = DialogState::default();
|
||||
}
|
||||
|
||||
fn dialog_closed(&mut self) {
|
||||
if self.player.is_some() && self.screen == super::Screen::Playing && self.title.is_none() {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
AfterAction::WaitForResponse => {
|
||||
self.dialogs.open = None;
|
||||
self.dialogs.waiting = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
match click {
|
||||
Some(Click::Custom { id, payload }) => {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_custom_click(&id, payload);
|
||||
}
|
||||
}
|
||||
Some(Click::RunCommand(command)) => {
|
||||
// In configuration there is nothing to run it (vanilla warns).
|
||||
if self.player.is_some() {
|
||||
let command = command.strip_prefix('/').unwrap_or(&command);
|
||||
self.send_chat(&format!("/{command}"));
|
||||
} else {
|
||||
log::warn!(
|
||||
"Horizon: commands are not supported in configuration, trying to run '{command}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Click::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
|
||||
Some(Click::CopyToClipboard(value)) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
|
||||
None => {}
|
||||
}
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
|
||||
fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
match dialog {
|
||||
Tag::String(id) => {
|
||||
let id = id.to_string_lossy();
|
||||
let name = if id.contains(':') {
|
||||
id.clone()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let found = self.remote.as_ref().and_then(|r| {
|
||||
let entries = r.registries.registries.get("minecraft:dialog")?;
|
||||
entries.iter().find(|(n, _)| *n == name)?.1.clone()
|
||||
});
|
||||
match found {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
tag => self.show_dialog(tag),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundShowDialogPacket` in play: a registry entry or the dialog.
|
||||
pub(super) fn show_play_dialog(
|
||||
&mut self,
|
||||
dialog: &horizon_protocol::packets::play::DialogHolder,
|
||||
) {
|
||||
use horizon_protocol::packets::play::DialogHolder;
|
||||
match dialog {
|
||||
DialogHolder::Direct(tag) => self.show_dialog(tag),
|
||||
DialogHolder::Registry(id) => {
|
||||
let tag = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.registries.data("minecraft:dialog", *id).cloned());
|
||||
match tag {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog_layout(&self) -> Option<Layout> {
|
||||
let open = self.dialogs.open.as_ref()?;
|
||||
Some(Layout::new(
|
||||
&open.dialog,
|
||||
&self.gui.atlas.font,
|
||||
self.gui_size(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn draw_dialog(&mut self, batch: &mut HudBatch) {
|
||||
if !self.dialog_open() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let cursor = self.cursor;
|
||||
let atlas = &self.gui.atlas;
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
if self.dialogs.warning {
|
||||
let (title, message, [disconnect, back]) = warning_texts();
|
||||
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
|
||||
batch,
|
||||
atlas,
|
||||
cursor,
|
||||
[&disconnect, &back],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// HeaderAndFooterLayout(33, 0) centred: the title, the button 33 below.
|
||||
let ticks = (since.elapsed().as_millis() / 50) as u32;
|
||||
let (label, visible, active) = waiting_button(ticks);
|
||||
let title = Text::translate("gui.waitingForResponse.title", &[]);
|
||||
let top = ((h - 53.0) / 2.0).trunc();
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
((w - tw) / 2.0).trunc(),
|
||||
top + 12.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
if visible {
|
||||
let (bx, by) = (((w - 200.0) / 2.0).trunc(), top + 33.0);
|
||||
let hover = ui::hovered(cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 200.0, 20.0, &label, active, hover);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let [ax, ay, aw, ah] = layout.area;
|
||||
open.scroll.clamp(ay, ay + ah, layout.content);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&open.dialog.title,
|
||||
layout.title.0,
|
||||
layout.title.1,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
|
||||
horizon_client::widgets::record("warning", layout.warning, true);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
|
||||
wx,
|
||||
wy,
|
||||
ww,
|
||||
wh,
|
||||
u32::MAX,
|
||||
);
|
||||
let buttons = open.dialog.buttons();
|
||||
let in_area = |y: f32| y >= ay && y < ay + ah;
|
||||
let mut tooltip = None;
|
||||
let mut start = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
start = Some(batch.vertices.len());
|
||||
} else if !clipped && let Some(s) = start.take() {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
match part {
|
||||
Part::Line(text, x, y) => {
|
||||
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
|
||||
}
|
||||
Part::Button { index, rect } => {
|
||||
let [x, y, bw, bh] = rect;
|
||||
let hover =
|
||||
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
|
||||
let b = buttons[index];
|
||||
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
|
||||
if hover && let Some(t) = &b.tooltip {
|
||||
tooltip = Some(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
if layout.content > ah {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
ax + aw - 6.0,
|
||||
ay,
|
||||
ay + ah,
|
||||
layout.content,
|
||||
open.scroll.amount,
|
||||
);
|
||||
}
|
||||
if warn_hover {
|
||||
tooltip = Some(Text::translate("menu.custom_screen_info.tooltip", &[]));
|
||||
}
|
||||
if let Some(t) = tooltip {
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&t, 170.0)
|
||||
.into_iter()
|
||||
.map(|l| l.0)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click on a dialog screen.
|
||||
pub(super) fn dialog_click(&mut self) -> Result<()> {
|
||||
let cursor = self.cursor;
|
||||
if self.dialogs.warning {
|
||||
let (title, message, _) = warning_texts();
|
||||
let answer = Confirm::new(&self.gui.atlas.font, self.gui_size(), &title, &message)
|
||||
.answer_at(cursor);
|
||||
match answer {
|
||||
Some(true) => {
|
||||
self.audio.play_button_click();
|
||||
let reason = Text::translate("menu.custom_screen_info.disconnect", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
}
|
||||
Some(false) => {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
let (w, h) = self.gui_size();
|
||||
let active = waiting_button((since.elapsed().as_millis() / 50) as u32).2;
|
||||
let (bx, by) = (
|
||||
((w - 200.0) / 2.0).trunc(),
|
||||
((h - 53.0) / 2.0).trunc() + 33.0,
|
||||
);
|
||||
if active && ui::hovered(cursor, bx, by, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
if ui::hovered(cursor, wx, wy, ww, wh) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = true;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
let mut action = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
|
||||
continue;
|
||||
}
|
||||
match part {
|
||||
Part::Button {
|
||||
index,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
action = Some(index);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(index) = action else {
|
||||
return Ok(());
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
let d = &open.dialog;
|
||||
let click = d.buttons()[index]
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|a| a.click(&d.inputs, &open.values));
|
||||
let after = d.after_action;
|
||||
self.run_dialog_action(click, after)
|
||||
}
|
||||
|
||||
/// The mouse wheel over a dialog: its body scrolls 10 a step.
|
||||
pub(super) fn dialog_scroll(&mut self, delta: f32) {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key pressed on a dialog screen: Escape closes what allows it.
|
||||
pub(super) fn dialog_key(&mut self, key: KeyCode) -> Result<()> {
|
||||
let escape = key == KeyCode::Escape;
|
||||
if self.dialogs.warning {
|
||||
if escape {
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// shouldCloseOnEsc: once Back works.
|
||||
if escape && waiting_button((since.elapsed().as_millis() / 50) as u32).2 {
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(open) = self.dialogs.open.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if escape {
|
||||
if !open.dialog.can_close_with_escape {
|
||||
return Ok(());
|
||||
}
|
||||
// onClose: the cancel action, and the screen closes.
|
||||
let d = &open.dialog;
|
||||
let click = d.on_cancel().and_then(|a| a.click(&d.inputs, &open.values));
|
||||
return self.run_dialog_action(click, AfterAction::Close);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `state` reports: the open dialog and its values, or the waiting
|
||||
/// and warning screens.
|
||||
pub(super) fn dialog_status(&self) -> serde_json::Value {
|
||||
let d = &self.dialogs;
|
||||
let screen = if d.warning {
|
||||
Some("warning")
|
||||
} else if d.waiting.is_some() {
|
||||
Some("waiting_for_response")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let open = d.open.as_ref().map(|open| open.dialog.json(&open.values));
|
||||
serde_json::json!({ "open": open, "screen": screen })
|
||||
}
|
||||
}
|
||||
@@ -489,6 +489,7 @@ impl Game<'_> {
|
||||
r.session.disconnect();
|
||||
}
|
||||
self.server_packs_disconnect();
|
||||
self.close_dialogs();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -736,6 +737,8 @@ impl Game<'_> {
|
||||
} => {
|
||||
self.set_registries(registries);
|
||||
self.extras_joined();
|
||||
// handleLogin: the level loading screen replaces any dialog.
|
||||
self.close_dialogs();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
@@ -764,6 +767,8 @@ impl Game<'_> {
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
@@ -2614,6 +2619,9 @@ impl Game<'_> {
|
||||
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
|
||||
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
|
||||
C::SetTitlesAnimation(p) => self.titles.set_times(p.fade_in, p.stay, p.fade_out),
|
||||
// ClientCommonPacketListenerImpl.handleShowDialog / handleClearDialog.
|
||||
C::ShowDialog(p) => self.show_play_dialog(&p.dialog),
|
||||
C::ClearDialog(_) => self.clear_dialog(),
|
||||
C::ClearTitles(p) => {
|
||||
self.titles.clear();
|
||||
if p.value {
|
||||
|
||||
@@ -54,6 +54,8 @@ pub mod datapack;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
|
||||
@@ -55,6 +55,10 @@ pub enum Event {
|
||||
PackPush(cfg::ResourcePackPush),
|
||||
/// ClientboundResourcePackPopPacket: one pack, or every pack.
|
||||
PackPop(Option<u128>),
|
||||
/// `show_dialog` in configuration: the dialog itself.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// `clear_dialog` in configuration.
|
||||
ClearDialog,
|
||||
/// `ClientboundTransferPacket`: the server sent us to another address.
|
||||
/// The connection ends right after; the game reconnects there with the
|
||||
/// stored cookies.
|
||||
@@ -234,6 +238,25 @@ impl Session {
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
/// ServerboundCustomClickActionPacket (a dialog's answer), in whichever
|
||||
/// phase the connection is.
|
||||
pub fn send_custom_click(&self, id: &str, payload: Option<horizon_world::nbt::Tag>) {
|
||||
let click = cfg::CustomClickAction {
|
||||
id: id.to_owned(),
|
||||
payload,
|
||||
};
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
let body = if self.configuring.load(Ordering::Relaxed) {
|
||||
cfg::Serverbound::CustomClickAction(click).encode()
|
||||
} else {
|
||||
play::Serverbound::CustomClickAction(click).encode()
|
||||
};
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&body).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(w) = self.writer.lock().unwrap().take() {
|
||||
w.shutdown();
|
||||
@@ -602,6 +625,13 @@ fn configure(
|
||||
cfg::Clientbound::ResourcePackPop(p) => {
|
||||
let _ = send.send(Event::PackPop(p.id));
|
||||
}
|
||||
// The game shows it; the answer goes back from there.
|
||||
cfg::Clientbound::ShowDialog(d) => {
|
||||
let _ = send.send(Event::ShowDialog(d.dialog));
|
||||
}
|
||||
cfg::Clientbound::ClearDialog(_) => {
|
||||
let _ = send.send(Event::ClearDialog);
|
||||
}
|
||||
cfg::Clientbound::CookieRequest(q) => {
|
||||
let payload = cookies.lock().unwrap().get(&q.key).cloned();
|
||||
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
|
||||
|
||||
@@ -45,6 +45,32 @@ packet!(
|
||||
packet!(KnownPack { namespace: String => Str, id: String => Str, version: String => Str });
|
||||
packet!(SelectKnownPacks { packs: Vec<KnownPack> => List<KnownPack> });
|
||||
packet!(CodeOfConduct { text: String => Str });
|
||||
packet!(
|
||||
/// ClientboundShowDialogPacket.CONTEXT_FREE_STREAM_CODEC: the dialog
|
||||
/// itself (`Dialog.DIRECT_CODEC` as NBT).
|
||||
ShowDialog { dialog: Tag => Nbt }
|
||||
);
|
||||
packet!(
|
||||
/// ServerboundCustomClickActionPacket: a `custom` click event's id and
|
||||
/// payload (a dialog's answer).
|
||||
CustomClickAction { id: String => Ident, payload: Option<Tag> => ClickPayload }
|
||||
);
|
||||
/// ServerboundCustomClickActionPacket.UNTRUSTED_TAG_CODEC: an optional tag
|
||||
/// (`End` when there is none) behind its length, 65536 bytes at most.
|
||||
pub struct ClickPayload;
|
||||
impl Codec<Option<Tag>> for ClickPayload {
|
||||
fn read(r: &mut Reader) -> Result<Option<Tag>> {
|
||||
let n = r.count(65536)?;
|
||||
let tag = Reader::new(r.bytes(n)?).nbt()?;
|
||||
Ok((tag != Tag::End).then_some(tag))
|
||||
}
|
||||
fn write(v: &Option<Tag>, w: &mut Writer) {
|
||||
let mut tag = Writer::new();
|
||||
tag.nbt(v.as_ref().unwrap_or(&Tag::End));
|
||||
w.varint(tag.data.len() as i32);
|
||||
w.bytes(&tag.data);
|
||||
}
|
||||
}
|
||||
|
||||
packet_enum!(Clientbound, crate::ids::configuration::clientbound {
|
||||
CookieRequest(CookieRequest) = COOKIE_REQUEST,
|
||||
@@ -63,6 +89,7 @@ packet_enum!(Clientbound, crate::ids::configuration::clientbound {
|
||||
UpdateTags(UpdateTags) = UPDATE_TAGS,
|
||||
SelectKnownPacks(SelectKnownPacks) = SELECT_KNOWN_PACKS,
|
||||
ClearDialog(Empty) = CLEAR_DIALOG,
|
||||
ShowDialog(ShowDialog) = SHOW_DIALOG,
|
||||
CodeOfConduct(CodeOfConduct) = CODE_OF_CONDUCT,
|
||||
});
|
||||
|
||||
@@ -142,6 +169,7 @@ packet_enum!(Serverbound, crate::ids::configuration::serverbound {
|
||||
ResourcePack(ResourcePackResponse) = RESOURCE_PACK,
|
||||
SelectKnownPacks(SelectKnownPacks) = SELECT_KNOWN_PACKS,
|
||||
AcceptCodeOfConduct(Empty) = ACCEPT_CODE_OF_CONDUCT,
|
||||
CustomClickAction(CustomClickAction) = CUSTOM_CLICK_ACTION,
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -208,6 +236,10 @@ mod tests {
|
||||
Clientbound::CodeOfConduct(CodeOfConduct {
|
||||
text: "Be nice".into(),
|
||||
}),
|
||||
Clientbound::ShowDialog(ShowDialog {
|
||||
dialog: Tag::compound([("type", Tag::String("minecraft:notice".into()))]),
|
||||
}),
|
||||
Clientbound::ClearDialog(Empty),
|
||||
] {
|
||||
round_trip(p, Clientbound::encode, Clientbound::decode);
|
||||
}
|
||||
@@ -219,8 +251,28 @@ mod tests {
|
||||
Serverbound::ResourcePack(ResourcePackResponse { id: 5, action: 1 }),
|
||||
Serverbound::SelectKnownPacks(SelectKnownPacks { packs: vec![] }),
|
||||
Serverbound::AcceptCodeOfConduct(Empty),
|
||||
Serverbound::CustomClickAction(CustomClickAction {
|
||||
id: "authme:prejoin-register/submit".into(),
|
||||
payload: Some(Tag::compound([("password", Tag::String("hunter2".into()))])),
|
||||
}),
|
||||
Serverbound::CustomClickAction(CustomClickAction {
|
||||
id: "minecraft:x".into(),
|
||||
payload: None,
|
||||
}),
|
||||
] {
|
||||
round_trip(p, Serverbound::encode, Serverbound::decode);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes vanilla writes: the length, then the tag (a lone `End` byte
|
||||
/// for none).
|
||||
#[test]
|
||||
fn click_payload_is_length_prefixed() {
|
||||
let mut w = Writer::new();
|
||||
ClickPayload::write(&None, &mut w);
|
||||
assert_eq!(w.data, [1, 0]);
|
||||
let mut w = Writer::new();
|
||||
ClickPayload::write(&Some(Tag::Byte(1)), &mut w);
|
||||
assert_eq!(w.data, [2, 1, 1]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ use crate::{
|
||||
packets::{
|
||||
Empty,
|
||||
configuration::{
|
||||
ClientInformation, CookieRequest, CookieResponse, CustomPayload, Disconnect, KeepAlive,
|
||||
Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse, StoreCookie, Transfer,
|
||||
UpdateTags,
|
||||
ClientInformation, CookieRequest, CookieResponse, CustomClickAction, CustomPayload,
|
||||
Disconnect, KeepAlive, Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse,
|
||||
StoreCookie, Transfer, UpdateTags,
|
||||
},
|
||||
login::Property,
|
||||
},
|
||||
@@ -190,6 +190,35 @@ impl Codec<i32> for HolderId {
|
||||
w.varint(v + 1)
|
||||
}
|
||||
}
|
||||
/// `Holder<Dialog>` as Dialog.STREAM_CODEC sends it: an entry of the
|
||||
/// `minecraft:dialog` registry the server sent in configuration, or the
|
||||
/// dialog itself.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DialogHolder {
|
||||
Registry(i32),
|
||||
Direct(Tag),
|
||||
}
|
||||
impl Codec<DialogHolder> for DialogHolder {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
Ok(match r.varint()? {
|
||||
0 => Self::Direct(r.nbt()?),
|
||||
id => Self::Registry(id - 1),
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
match v {
|
||||
Self::Registry(id) => w.varint(id + 1),
|
||||
Self::Direct(tag) => {
|
||||
w.varint(0);
|
||||
w.nbt(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
packet!(
|
||||
/// ClientboundShowDialogPacket.
|
||||
ShowDialog { dialog: DialogHolder => DialogHolder }
|
||||
);
|
||||
packet!(
|
||||
/// ChatType.Bound.
|
||||
ChatTypeBound { chat_type: i32 => HolderId, name: Tag => Nbt, target: Option<Tag> => Opt<Nbt> }
|
||||
@@ -1854,6 +1883,8 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
UpdateMobEffect(UpdateMobEffect) = UPDATE_MOB_EFFECT,
|
||||
UpdateTags(UpdateTags) = UPDATE_TAGS,
|
||||
TrackedWaypoint(TrackedWaypoint) = WAYPOINT,
|
||||
ClearDialog(Empty) = CLEAR_DIALOG,
|
||||
ShowDialog(ShowDialog) = SHOW_DIALOG,
|
||||
});
|
||||
packet!(
|
||||
/// ClientboundClearTitlesPacket: whether to also reset the timings.
|
||||
@@ -2229,6 +2260,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
ContainerClose(ContainerClose) = CONTAINER_CLOSE,
|
||||
CookieResponse(CookieResponse) = COOKIE_RESPONSE,
|
||||
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
|
||||
CustomClickAction(CustomClickAction) = CUSTOM_CLICK_ACTION,
|
||||
EditBook(EditBook) = EDIT_BOOK,
|
||||
Interact(Interact) = INTERACT,
|
||||
KeepAlive(KeepAlive) = KEEP_ALIVE,
|
||||
@@ -3024,6 +3056,7 @@ mod tests {
|
||||
w.varint(101);
|
||||
assert!(BookPages::read(&mut Reader::new(&w.data)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_event_round_trips() {
|
||||
let add = Clientbound::BossEvent(BossEvent {
|
||||
@@ -3061,4 +3094,28 @@ mod tests {
|
||||
Clientbound::decode,
|
||||
);
|
||||
}
|
||||
|
||||
/// A registry entry is its id + 1, the dialog itself a 0 then its NBT.
|
||||
#[test]
|
||||
fn dialogs_round_trip() {
|
||||
let notice = Tag::compound([("type", Tag::String("minecraft:notice".into()))]);
|
||||
for dialog in [DialogHolder::Registry(3), DialogHolder::Direct(notice)] {
|
||||
crate::packets::round_trip(
|
||||
Clientbound::ShowDialog(ShowDialog { dialog }),
|
||||
Clientbound::encode,
|
||||
Clientbound::decode,
|
||||
);
|
||||
}
|
||||
let mut w = Writer::new();
|
||||
DialogHolder::write(&DialogHolder::Registry(3), &mut w);
|
||||
assert_eq!(w.data, [4]);
|
||||
crate::packets::round_trip(
|
||||
Serverbound::CustomClickAction(CustomClickAction {
|
||||
id: "minecraft:custom".into(),
|
||||
payload: None,
|
||||
}),
|
||||
Serverbound::encode,
|
||||
Serverbound::decode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Horizon's client on a vanilla 26.3 dedicated server, end to end.
|
||||
|
||||
A vanilla server (the pinned server jar, offline mode, a flat world) and one Horizon game joined to it,
|
||||
driven by horizon-ctl. The server's console does what a server or its plugins do; the checks read what
|
||||
the game shows and what reaches the server (its console and, for custom click actions, its debug log).
|
||||
|
||||
python3 tools/server_e2e.py --bin-dir target/fast # every check
|
||||
python3 tools/server_e2e.py --bin-dir target/fast --check dialog
|
||||
|
||||
Checks:
|
||||
- dialog: `/dialog show` with a multi-action dialog (a message, three buttons): the game shows it,
|
||||
a command runs on the server (`trigger`), a custom action reaches it with its
|
||||
payload (the server's "Received custom click action" line), `wait_for_response` leaves the
|
||||
waiting screen, and `/dialog clear` closes a dialog (servers used to see nothing: the game
|
||||
dropped the packets).
|
||||
|
||||
The game runs with fresh HORIZON_HOME, launcher and XDG folders and without audio. Screenshots and both
|
||||
logs go to --out.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import lab # noqa: E402
|
||||
import test_rust_parity as base # noqa: E402
|
||||
from agent_control_e2e import Failed # noqa: E402
|
||||
from lan_e2e import Game # noqa: E402
|
||||
from test_dedicated_server_parity import Server # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLAYER = 'Steve'
|
||||
PROPERTIES = {
|
||||
'online-mode': 'false',
|
||||
'white-list': 'false',
|
||||
'level-type': 'minecraft\\:flat',
|
||||
'generate-structures': 'false',
|
||||
'spawn-protection': '0',
|
||||
'view-distance': '3',
|
||||
'simulation-distance': '3',
|
||||
'pause-when-empty-seconds': '-1',
|
||||
}
|
||||
# MinecraftServer.handleCustomClickAction logs at debug level: the console shows that logger's debug lines.
|
||||
LOG4J = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders><Console name="out" target="SYSTEM_OUT"><PatternLayout pattern="[%level] %msg%n"/></Console></Appenders>
|
||||
<Loggers>
|
||||
<Logger name="net.minecraft.server.MinecraftServer" level="debug"/>
|
||||
<Root level="info"><AppenderRef ref="out"/></Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
"""
|
||||
|
||||
|
||||
def snbt(value):
|
||||
"""A JSON value as SNBT (strings quoted, booleans as bytes)."""
|
||||
if isinstance(value, bool):
|
||||
return '1b' if value else '0b'
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value)
|
||||
if isinstance(value, list):
|
||||
return '[' + ','.join(snbt(v) for v in value) + ']'
|
||||
if isinstance(value, dict):
|
||||
return '{' + ','.join(f'{k}:{snbt(v)}' for k, v in value.items()) + '}'
|
||||
return str(value)
|
||||
|
||||
|
||||
def check_dialog(server, game, out):
|
||||
server.console('scoreboard objectives add answer trigger')
|
||||
server.console(f'scoreboard players enable {PLAYER} answer')
|
||||
dialog = {
|
||||
'type': 'minecraft:multi_action',
|
||||
'title': {'text': 'Sign in', 'color': 'gold'},
|
||||
'body': {'type': 'minecraft:plain_message', 'contents': 'Pick a number.'},
|
||||
'actions': [
|
||||
{'label': 'Send', 'action': {'type': 'minecraft:run_command', 'command': 'trigger answer set 41'}},
|
||||
{'label': 'Submit', 'action': {'type': 'minecraft:dynamic/custom', 'id': 'horizon:e2e',
|
||||
'additions': {'tag': 'x'}}},
|
||||
{'label': 'Wait', 'action': {'type': 'minecraft:dynamic/custom', 'id': 'horizon:wait'}},
|
||||
],
|
||||
'after_action': 'none',
|
||||
'pause': False, # a pausing dialog must close after an action
|
||||
}
|
||||
shown = {}
|
||||
server.console(f'dialog show {PLAYER} {snbt(dialog)}')
|
||||
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
|
||||
state = game('state')
|
||||
shown['shown'] = state['dialog']['open']
|
||||
assert shown['shown']['title'] == 'Sign in', shown
|
||||
assert shown['shown']['buttons'] == ['Send', 'Submit', 'Wait'], shown
|
||||
game.shot(out, 'dialog')
|
||||
game.raw('click', 'send')
|
||||
time.sleep(1)
|
||||
server.console(f'scoreboard players get {PLAYER} answer')
|
||||
server.wait_for(rf'{PLAYER} has 41 \[answer\]', timeout=30)
|
||||
shown['trigger'] = 41
|
||||
# A custom action and its additions.
|
||||
game.raw('click', 'submit')
|
||||
server.wait_for(r'Received custom click action horizon:e2e with payload', timeout=30)
|
||||
line = next(l for l in server.lines if 'horizon:e2e' in l)
|
||||
shown['custom'] = line.split('payload ', 1)[1]
|
||||
assert shown['custom'] == '{tag:"x"}', shown
|
||||
# after_action none: still open. Then clear it.
|
||||
assert game('state')['dialog']['open'], 'the dialog closed after an action with after_action none'
|
||||
server.console(f'dialog clear {PLAYER}')
|
||||
game.raw('wait-for', 'screen', 'playing', '--timeout', 30)
|
||||
# wait_for_response: the waiting screen until the server answers (here, never).
|
||||
server.console(f'dialog show {PLAYER} {snbt(dict(dialog, after_action="wait_for_response"))}')
|
||||
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
|
||||
game.raw('click', 'wait')
|
||||
server.wait_for(r'Received custom click action horizon:wait', timeout=30)
|
||||
shown['waiting'] = game('state')['dialog']['screen']
|
||||
game.shot(out, 'waiting')
|
||||
assert shown['waiting'] == 'waiting_for_response', shown
|
||||
# Its Back works after 5 s, and so does Escape (clear_dialog leaves it open, as in vanilla).
|
||||
time.sleep(5.5)
|
||||
game.raw('key', 'esc', 'tap')
|
||||
game.raw('wait-for', 'screen', 'playing', '--timeout', 10)
|
||||
return shown
|
||||
|
||||
|
||||
CHECKS = {'dialog': check_dialog}
|
||||
|
||||
|
||||
def run(bins, out, checks):
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
work = Path(tempfile.mkdtemp(prefix='hz-server-e2e-'))
|
||||
port = lab.free_port()
|
||||
directory = work / 'server'
|
||||
directory.mkdir()
|
||||
props = dict(PROPERTIES, **{'server-port': str(port)})
|
||||
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
|
||||
(directory / 'eula.txt').write_text('eula=true\n')
|
||||
(work / 'log4j2.xml').write_text(LOG4J)
|
||||
classpath = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
||||
server = Server(['java', '-Xmx2g', f'-Dlog4j.configurationFile={work / "log4j2.xml"}', '-cp', classpath,
|
||||
'net.minecraft.server.Main', 'nogui'], directory, 'vanilla')
|
||||
game = Game('client', bins / 'horizon-ctl', lab.RESOURCES.resolve())
|
||||
for key in ('XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'HORIZON_LAUNCHER_HOME'):
|
||||
game.d.env[key] = str(game.work / key.lower())
|
||||
game.d.env['HORIZON_LAUNCHER_NO_SELF_UPDATE'] = '1'
|
||||
results = {}
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
server.console('gamerule spawn_mobs false')
|
||||
game('launch', wait=False, offscreen=sys.platform == 'darwin')
|
||||
game.raw('connect', f'127.0.0.1:{port}', PLAYER)
|
||||
game.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
server.wait_for(rf'{PLAYER} joined the game')
|
||||
server.console(f'op {PLAYER}')
|
||||
game.wait(20)
|
||||
for name in checks:
|
||||
try:
|
||||
results[name] = {'pass': True, 'detail': CHECKS[name](server, game, out)}
|
||||
except (Failed, AssertionError, StopIteration, KeyError) as e:
|
||||
results[name] = {'pass': False, 'detail': repr(e)}
|
||||
print(f'[server] {name}: {"PASS" if results[name]["pass"] else "FAIL"} {results[name]["detail"]}')
|
||||
finally:
|
||||
(out / 'client.log').write_text(game.close())
|
||||
server.stop()
|
||||
shutil.copy(server.log_path, out / 'server.log')
|
||||
shutil.rmtree(game.work, ignore_errors=True)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--check', action='append', choices=list(CHECKS), help='run only these checks (repeatable)')
|
||||
parser.add_argument('--out', default=str(ROOT / '.local/server-e2e'), help='screenshots and logs go here')
|
||||
args = parser.parse_args()
|
||||
if args.bin_dir:
|
||||
bins = Path(args.bin_dir).resolve()
|
||||
else:
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'],
|
||||
cwd=ROOT, check=True)
|
||||
bins = ROOT / 'target/fast'
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
results = run(bins, out, args.check or list(CHECKS))
|
||||
print(json.dumps(results, indent=1))
|
||||
sys.exit(0 if results and all(r['pass'] for r in results.values()) else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user