874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
454 lines
18 KiB
Rust
454 lines
18 KiB
Rust
//! `WinScreen`: the end poem, the credits and the post-credits text rolling up
|
|
//! under the logo, after the player leaves the End for the first time (with
|
|
//! the poem) or from Credits & Attribution (the credits alone). The texts come
|
|
//! from the resource root, so resource packs can replace them.
|
|
use crate::{
|
|
font::Font,
|
|
hud::{Blend, EndPortalFill, HudAtlas, HudBatch},
|
|
text::{self, Style, Text},
|
|
};
|
|
use horizon_core::random::{LegacyRandom, RandomSource};
|
|
use std::{collections::HashSet, path::Path};
|
|
|
|
/// `WinScreen.OBFUSCATE_TOKEN`: `§f§k§a§b` marks where the poem hides a name.
|
|
const OBFUSCATE_TOKEN: &str = "\u{a7}f\u{a7}k\u{a7}a\u{a7}b";
|
|
/// `WinScreen.NAME_PREFIX`.
|
|
const NAME_PREFIX: &str = " ";
|
|
/// `WinScreen.SECTION_HEADING`.
|
|
const SECTION_HEADING: &str = "============";
|
|
/// A key the screen reacts to (`keyPressed` / `keyReleased`).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub enum Key {
|
|
/// `KeyEvent.isUp`: the up arrow scrolls back.
|
|
Up,
|
|
/// Space speeds the scroll up.
|
|
Space,
|
|
/// Each held Control key speeds it up more.
|
|
LeftControl,
|
|
RightControl,
|
|
}
|
|
impl Key {
|
|
pub fn from_code(code: winit::keyboard::KeyCode) -> Option<Self> {
|
|
use winit::keyboard::KeyCode;
|
|
match code {
|
|
KeyCode::ArrowUp => Some(Self::Up),
|
|
KeyCode::Space => Some(Self::Space),
|
|
KeyCode::ControlLeft => Some(Self::LeftControl),
|
|
KeyCode::ControlRight => Some(Self::RightControl),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
pub struct WinScreen {
|
|
pub poem: bool,
|
|
lines: Vec<Text>,
|
|
centered: HashSet<usize>,
|
|
total_scroll_length: i32,
|
|
scroll: f32,
|
|
speedup_active: bool,
|
|
speedup_modifiers: HashSet<Key>,
|
|
unmodified_scroll_speed: f32,
|
|
scroll_speed: f32,
|
|
direction: i32,
|
|
}
|
|
impl WinScreen {
|
|
/// `WinScreen.<init>` and `init`: the poem (`texts/end.txt`) when `poem`,
|
|
/// the credits (`texts/credits.json`), then `texts/postcredits.txt`.
|
|
pub fn new(poem: bool, root: &Path, player: &str, font: &Font) -> Self {
|
|
let speed = if poem { 0.5 } else { 0.75 };
|
|
let mut screen = Self {
|
|
poem,
|
|
lines: Vec::new(),
|
|
centered: HashSet::new(),
|
|
total_scroll_length: 0,
|
|
scroll: 0.0,
|
|
speedup_active: false,
|
|
speedup_modifiers: HashSet::new(),
|
|
unmodified_scroll_speed: speed,
|
|
scroll_speed: speed,
|
|
direction: 1,
|
|
};
|
|
let texts = root.join("assets/minecraft/texts");
|
|
let read = |name: &str| match std::fs::read_to_string(texts.join(name)) {
|
|
Ok(s) => Some(s),
|
|
Err(e) => {
|
|
log::error!("Couldn't load credits from file minecraft:texts/{name}: {e}");
|
|
None
|
|
}
|
|
};
|
|
if poem && let Some(s) = read("end.txt") {
|
|
screen.add_poem_file(&s, player, font);
|
|
}
|
|
if let Some(s) = read("credits.json") {
|
|
match serde_json::from_str(&s) {
|
|
Ok(json) => screen.add_credits_file(&json),
|
|
Err(e) => {
|
|
log::error!("Couldn't load credits from file minecraft:texts/credits.json: {e}")
|
|
}
|
|
}
|
|
}
|
|
if poem && let Some(s) = read("postcredits.txt") {
|
|
screen.add_poem_file(&s, player, font);
|
|
}
|
|
screen.total_scroll_length = screen.lines.len() as i32 * 12;
|
|
screen
|
|
}
|
|
/// `addPoemFile`: each line with the player's name in, its obfuscated
|
|
/// spans turned into 3 to 6 scrambled characters, wrapped at 256 pixels
|
|
/// and followed by an empty line; 8 more empty lines at the end.
|
|
fn add_poem_file(&mut self, file: &str, player: &str, font: &Font) {
|
|
let mut random = LegacyRandom::new(8124371);
|
|
// BufferedReader.readLine: \n, \r or \r\n end a line; no empty last line.
|
|
let mut lines: Vec<&str> = file
|
|
.split('\n')
|
|
.map(|l| l.strip_suffix('\r').unwrap_or(l))
|
|
.collect();
|
|
if file.ends_with('\n') {
|
|
lines.pop();
|
|
}
|
|
for line in lines.iter().flat_map(|l| l.split('\r')) {
|
|
let mut line = line.replace("PLAYERNAME", player);
|
|
while let Some(pos) = line.find(OBFUSCATE_TOKEN) {
|
|
let n = random.next_int_bound(4).unwrap_or(0) as usize + 3;
|
|
line = format!(
|
|
"{}\u{a7}f\u{a7}k{}{}",
|
|
&line[..pos],
|
|
&"XXXXXXXX"[..n],
|
|
&line[pos + OBFUSCATE_TOKEN.len()..]
|
|
);
|
|
}
|
|
let text = Text::legacy(&line);
|
|
self.lines
|
|
.extend(font.split_lines(&text, 256.0).into_iter().map(|(t, _)| t));
|
|
self.add_empty_line();
|
|
}
|
|
for _ in 0..8 {
|
|
self.add_empty_line();
|
|
}
|
|
}
|
|
/// `addCreditsFile`: each section's heading, then each discipline's name
|
|
/// and its titles with the names under them.
|
|
fn add_credits_file(&mut self, json: &serde_json::Value) {
|
|
let colored = |s: &str, color: u32| Text::styled(s, Style::color(color));
|
|
let heading = || colored(SECTION_HEADING, text::WHITE);
|
|
for section in json.as_array().into_iter().flatten() {
|
|
let name = section["section"].as_str().unwrap_or_default();
|
|
self.add_credits_line(heading(), true);
|
|
self.add_credits_line(colored(name, text::YELLOW), true);
|
|
self.add_credits_line(heading(), true);
|
|
self.add_empty_line();
|
|
self.add_empty_line();
|
|
for discipline in section["disciplines"].as_array().into_iter().flatten() {
|
|
let name = discipline["discipline"].as_str().unwrap_or_default();
|
|
if !name.is_empty() {
|
|
self.add_credits_line(colored(name, text::YELLOW), true);
|
|
self.add_empty_line();
|
|
self.add_empty_line();
|
|
}
|
|
for title in discipline["titles"].as_array().into_iter().flatten() {
|
|
let name = title["title"].as_str().unwrap_or_default();
|
|
self.add_credits_line(colored(name, text::GRAY), false);
|
|
for name in title["names"].as_array().into_iter().flatten() {
|
|
let line = format!("{NAME_PREFIX}{}", name.as_str().unwrap_or_default());
|
|
self.add_credits_line(colored(&line, text::WHITE), false);
|
|
}
|
|
self.add_empty_line();
|
|
self.add_empty_line();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn add_empty_line(&mut self) {
|
|
self.lines.push(Text::empty());
|
|
}
|
|
fn add_credits_line(&mut self, line: Text, centered: bool) {
|
|
if centered {
|
|
self.centered.insert(self.lines.len());
|
|
}
|
|
self.lines.push(line);
|
|
}
|
|
/// The lines in order and whether each is centred.
|
|
pub fn lines(&self) -> impl Iterator<Item = (&Text, bool)> {
|
|
self.lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, l)| (l, self.centered.contains(&i)))
|
|
}
|
|
fn calculate_scroll_speed(&self) -> f32 {
|
|
let speed = if self.speedup_active {
|
|
self.unmodified_scroll_speed * (5.0 + self.speedup_modifiers.len() as f32 * 15.0)
|
|
} else {
|
|
self.unmodified_scroll_speed
|
|
};
|
|
speed * self.direction as f32
|
|
}
|
|
/// `keyPressed` / `keyReleased` (Escape closes the screen: `onClose`).
|
|
pub fn key(&mut self, key: Key, pressed: bool) {
|
|
match (key, pressed) {
|
|
(Key::Up, _) => self.direction = if pressed { -1 } else { 1 },
|
|
(Key::Space, _) => self.speedup_active = pressed,
|
|
(_, true) => {
|
|
self.speedup_modifiers.insert(key);
|
|
}
|
|
(_, false) => {
|
|
self.speedup_modifiers.remove(&key);
|
|
}
|
|
}
|
|
self.scroll_speed = self.calculate_scroll_speed();
|
|
}
|
|
/// `tick`: once the text has rolled past the top, the screen is done
|
|
/// (`respawn`, or back to the screen that opened it).
|
|
pub fn finished(&self, height: f32) -> bool {
|
|
let height = height as i32;
|
|
self.scroll > (self.total_scroll_length + height + height + 24) as f32
|
|
}
|
|
pub fn scroll(&self) -> f32 {
|
|
self.scroll
|
|
}
|
|
/// Stay `scroll` pixels in (until a key changes the speed), for
|
|
/// screenshots.
|
|
pub fn hold(&mut self, scroll: f32) {
|
|
self.scroll = scroll;
|
|
self.scroll_speed = 0.0;
|
|
}
|
|
/// `extractRenderState` after a frame of `delta_ticks` game ticks:
|
|
/// `extractBackground`, the vignette, then the logo and the lines
|
|
/// scrolled up. The poem's End portal background samples `end_portal`;
|
|
/// the credits draw the scrolling menu background (over the blurred
|
|
/// panorama on the title screen).
|
|
pub fn draw(
|
|
&mut self,
|
|
batch: &mut HudBatch,
|
|
atlas: &HudAtlas,
|
|
delta_ticks: f32,
|
|
end_portal: EndPortalFill,
|
|
) {
|
|
let (w, h) = (batch.width, batch.height);
|
|
if self.poem {
|
|
let start = batch.vertices.len();
|
|
batch.fill(atlas, 0.0, 0.0, w, h, u32::MAX);
|
|
batch.blend_since(start, Blend::EndPortal);
|
|
batch.end_portal = end_portal;
|
|
} else {
|
|
self.menu_background(batch, atlas, w, h);
|
|
}
|
|
// extractVignette: credits_vignette.png stretched over the screen.
|
|
if let Some(r) = atlas.sprites.get("misc/credits_vignette").copied() {
|
|
let start = batch.vertices.len();
|
|
batch.rect_uv(0.0, 0.0, w, h, r, u32::MAX);
|
|
batch.blend_since(start, Blend::Vignette);
|
|
}
|
|
self.scroll = (self.scroll + delta_ticks * self.scroll_speed).max(0.0);
|
|
let logo_x = (w / 2.0).floor() - 128.0;
|
|
let logo_y = h + 50.0;
|
|
// Everything below is drawn translated by `y_offs`.
|
|
let y_offs = -self.scroll;
|
|
// LogoRenderer(false) at full alpha.
|
|
let edition_x = (w / 2.0).floor() - 64.0;
|
|
batch.sprite_region(
|
|
atlas,
|
|
"gui/title/minecraft",
|
|
[logo_x, logo_y + y_offs, 256.0, 44.0],
|
|
256.0,
|
|
64.0,
|
|
);
|
|
batch.sprite_region(
|
|
atlas,
|
|
crate::title_edition::SPRITE,
|
|
[edition_x, logo_y + 44.0 - 7.0 + y_offs, 128.0, 14.0],
|
|
128.0,
|
|
16.0,
|
|
);
|
|
let mut y_pos = logo_y + 100.0;
|
|
let last = self.lines.len().saturating_sub(1);
|
|
for (i, line) in self.lines.iter().enumerate() {
|
|
let mut shift = y_offs;
|
|
if i == last {
|
|
// The last line stops in the middle of the screen.
|
|
let diff = y_pos + y_offs - ((h as i32 / 2) as f32 - 6.0);
|
|
if diff < 0.0 {
|
|
shift -= diff;
|
|
}
|
|
}
|
|
if y_pos + y_offs + 12.0 + 8.0 > 0.0 && y_pos + y_offs < h {
|
|
let x = if self.centered.contains(&i) {
|
|
// GuiGraphicsExtractor.centeredText: x - width / 2.
|
|
logo_x + 128.0 - (atlas.font.width_text(line) as i32 / 2) as f32
|
|
} else {
|
|
logo_x
|
|
};
|
|
batch.text_styled(atlas, line, x, y_pos + shift, u32::MAX, true);
|
|
}
|
|
y_pos += 12.0;
|
|
}
|
|
}
|
|
/// `extractMenuBackground`: `menu_background.png` tiled at 32 pixels,
|
|
/// scrolled by half the text's scroll.
|
|
fn menu_background(&self, batch: &mut HudBatch, atlas: &HudAtlas, w: f32, h: f32) {
|
|
let v = (self.scroll * 0.5).rem_euclid(32.0);
|
|
let start = batch.vertices.len();
|
|
crate::ui::tiled(
|
|
batch,
|
|
atlas,
|
|
"gui/menu_background",
|
|
0.0,
|
|
-v,
|
|
w,
|
|
h + 32.0,
|
|
32.0,
|
|
u32::MAX,
|
|
);
|
|
batch.clip_since(start, 0.0, 0.0, w, h);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A resource root with only the credits texts: every glyph is the
|
|
/// missing one, 6 pixels wide.
|
|
fn screen(poem: bool, end: &str, credits: &str, post: &str) -> WinScreen {
|
|
// One directory per call: tests run in parallel, and two of them with
|
|
// the same arguments used to remove each other's files.
|
|
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let root =
|
|
std::env::temp_dir().join(format!("horizon-win-screen-{}-{n}", std::process::id()));
|
|
let texts = root.join("assets/minecraft/texts");
|
|
std::fs::create_dir_all(&texts).unwrap();
|
|
std::fs::write(texts.join("end.txt"), end).unwrap();
|
|
std::fs::write(texts.join("credits.json"), credits).unwrap();
|
|
std::fs::write(texts.join("postcredits.txt"), post).unwrap();
|
|
let atlas = HudAtlas::fonts(&root).unwrap();
|
|
let screen = WinScreen::new(poem, &root, "Steve", &atlas.font);
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
screen
|
|
}
|
|
const CREDITS: &str = r#"[{"section":"Studio","disciplines":[
|
|
{"discipline":"","titles":[{"title":"Head","names":["Ada","Bo"]}]},
|
|
{"discipline":"Art","titles":[{"title":"Artist","names":[]}]}]}]"#;
|
|
|
|
#[test]
|
|
fn the_poem_names_the_player_and_scrambles_its_hidden_names() {
|
|
let s = screen(
|
|
true,
|
|
"\u{a7}3Hello PLAYERNAME.\r\n\n\u{a7}2\u{a7}f\u{a7}k\u{a7}a\u{a7}bkey\n",
|
|
"[]",
|
|
"End.",
|
|
);
|
|
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
|
|
// Each line then an empty one; the empty line adds no text line; 8
|
|
// empty lines after each file.
|
|
let mut want = vec!["Hello Steve.", ""];
|
|
want.push("");
|
|
// LegacyRandom(8124371).nextInt(4) + 3 scrambled characters.
|
|
let n = LegacyRandom::new(8124371).next_int_bound(4).unwrap() as usize + 3;
|
|
let hidden = format!("{}key", &"XXXXXXXX"[..n]);
|
|
want.extend([hidden.as_str(), ""]);
|
|
want.extend([""; 8]);
|
|
want.extend(["End.", ""]);
|
|
want.extend([""; 8]);
|
|
assert_eq!(
|
|
lines,
|
|
want.iter()
|
|
.map(|l| (l.to_string(), false))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
let hidden_line = s.lines().nth(3).unwrap().0;
|
|
let styles: Vec<_> = hidden_line.chars().map(|(_, s)| s.obfuscated).collect();
|
|
assert!(styles[..n].iter().all(|o| *o == Some(true)));
|
|
assert_eq!(s.total_scroll_length, lines.len() as i32 * 12);
|
|
}
|
|
|
|
#[test]
|
|
fn long_poem_lines_wrap_at_256_pixels() {
|
|
// 6-pixel glyphs: 42 fit a line, and the space at each break goes.
|
|
let line = vec!["a"; 50].join(" ");
|
|
let s = screen(true, &line, "[]", "");
|
|
let lines: Vec<String> = s.lines().take(4).map(|(t, _)| t.plain()).collect();
|
|
assert_eq!(
|
|
lines.iter().map(|l| l.chars().count()).collect::<Vec<_>>(),
|
|
[41, 41, 15, 0]
|
|
);
|
|
assert!(lines.iter().all(|l| !l.starts_with(' ')));
|
|
}
|
|
#[test]
|
|
fn credits_centre_the_headings_and_list_the_names() {
|
|
let s = screen(false, "ignored", CREDITS, "ignored");
|
|
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
|
|
let l = |s: &str, c| (s.to_string(), c);
|
|
let names = |n: &str| l(&format!("{NAME_PREFIX}{n}"), false);
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
l(SECTION_HEADING, true),
|
|
l("Studio", true),
|
|
l(SECTION_HEADING, true),
|
|
l("", false),
|
|
l("", false),
|
|
l("Head", false),
|
|
names("Ada"),
|
|
names("Bo"),
|
|
l("", false),
|
|
l("", false),
|
|
l("Art", true),
|
|
l("", false),
|
|
l("", false),
|
|
l("Artist", false),
|
|
l("", false),
|
|
l("", false),
|
|
]
|
|
);
|
|
let color = |i: usize| s.lines().nth(i).unwrap().0.chars().next().unwrap().1.color;
|
|
assert_eq!(
|
|
[color(0), color(1), color(5), color(6), color(10)],
|
|
[
|
|
Some(text::WHITE),
|
|
Some(text::YELLOW),
|
|
Some(text::GRAY),
|
|
Some(text::WHITE),
|
|
Some(text::YELLOW)
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keys_set_the_scroll_speed_and_direction() {
|
|
let mut s = screen(false, "", CREDITS, "");
|
|
assert_eq!(s.scroll_speed, 0.75);
|
|
s.key(Key::Space, true);
|
|
assert_eq!(s.scroll_speed, 0.75 * 5.0);
|
|
s.key(Key::LeftControl, true);
|
|
s.key(Key::RightControl, true);
|
|
assert_eq!(s.scroll_speed, 0.75 * 35.0);
|
|
s.key(Key::Up, true);
|
|
assert_eq!(s.scroll_speed, -0.75 * 35.0);
|
|
s.key(Key::Space, false);
|
|
assert_eq!(s.scroll_speed, -0.75);
|
|
s.key(Key::Up, false);
|
|
s.key(Key::LeftControl, false);
|
|
assert_eq!(s.scroll_speed, 0.75);
|
|
// The poem rolls at 0.5 pixels a tick.
|
|
assert_eq!(screen(true, "", CREDITS, "").scroll_speed, 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn the_screen_ends_once_everything_rolled_past() {
|
|
let mut s = screen(false, "", CREDITS, "");
|
|
let atlas = HudAtlas::fonts(Path::new("/nonexistent")).unwrap();
|
|
let mut batch = HudBatch::new([640, 480]);
|
|
let h = batch.height;
|
|
let end = (s.total_scroll_length as f32 + 2.0 * h + 24.0) / 0.75;
|
|
s.draw(&mut batch, &atlas, end - 1.0, EndPortalFill::default());
|
|
assert!(!s.finished(h));
|
|
// Scrolling back never goes above the start.
|
|
s.key(Key::Up, true);
|
|
s.draw(&mut batch, &atlas, end * 2.0, EndPortalFill::default());
|
|
assert_eq!(s.scroll(), 0.0);
|
|
s.key(Key::Up, false);
|
|
s.draw(&mut batch, &atlas, end + 1.0, EndPortalFill::default());
|
|
assert!(s.finished(h));
|
|
}
|
|
}
|