Merge pull request #2632 from ludovic111/feat/launcher-skins

🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-27 07:32:21 +01:00
commit b6f18dc8f3
10 files changed
+642 -3

No files matched your search

+15
View File
@@ -144,6 +144,21 @@ Forget one of your offline names.
MCP: `account_forget_offline`.
## `horizon-launcher account skin`
Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape PNG, the model. Kept by the launcher and given to the game when that name plays.
horizon-launcher account skin <NAME> [FILE]
- `NAME`
- `FILE`: The skin's PNG
- `--model`: classic or slim arms
- `--cape`: The cape's PNG
- `--no-skin`: Back to the default skin
- `--no-cape`: Take the cape off
MCP: `account_skin`.
## `horizon-launcher settings`
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs).
+7
View File
@@ -129,6 +129,13 @@ The opening, then Play ([clip](docs/opening.webm)):
the Console's Share log takes them out of the log too. `horizon-launcher account offline
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
<name>`.
- **Skin and cape**: each offline name's **Skin…** in Settings > Account shows the front of its
skin and cape, takes a skin PNG (64×64, or the older 64×32) and a cape PNG (64×32), and picks
classic or slim arms. The launcher keeps copies in its `skins/` folder and gives the game
`HORIZON_OFFLINE_SKIN`, `HORIZON_OFFLINE_SKIN_MODEL` and `HORIZON_OFFLINE_CAPE` when that name
plays; the game draws the default skins until it reads them. A Microsoft account's skin and
cape: coming soon, with sign-in. `horizon-launcher account skin <name> [FILE] [--model
classic|slim] [--cape FILE] [--no-skin] [--no-cape]`.
- **Console**: Home's Console shows the game's output of this run as it comes: a search (every
word, any case), Follow to keep the newest line in view, Copy, and Open logs folder. Warnings
and errors stand out. **Share log…** uploads the log to [mclo.gs](https://mclo.gs), a paste
+20
View File
@@ -645,6 +645,9 @@ pub struct Accounts {
/// (`horizon-player.txt`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offline_selected: Option<String>,
/// Each offline name's skin and cape (`skins`), by the name in lowercase.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub looks: std::collections::BTreeMap<String, crate::skins::Look>,
}
/// Who plays, as the quick switch lists them.
@@ -761,6 +764,7 @@ impl Accounts {
pub fn remove_offline(&mut self, name: &str) -> Result<(), String> {
let before = self.offline.len();
self.offline.retain(|n| !n.eq_ignore_ascii_case(name));
self.looks.remove(&name.to_ascii_lowercase());
if self.offline.len() == before {
return Err(format!("No offline name {name}."));
}
@@ -774,6 +778,22 @@ impl Accounts {
Ok(())
}
/// An offline name's look (the default: no skin of its own).
pub fn look(&self, name: &str) -> crate::skins::Look {
self.looks
.get(&name.to_ascii_lowercase())
.cloned()
.unwrap_or_default()
}
/// The game's variables for the offline name that plays: its skin and cape.
pub fn look_env(&self) -> Vec<(&'static str, String)> {
match self.name_arg() {
Some(name) => crate::skins::env(&crate::skins::dir(), name, &self.look(name)),
None => Vec::new(),
}
}
/// The game's `--name` for this run: the offline name, when one plays.
pub fn name_arg(&self) -> Option<&str> {
match self.selected() {
+77 -1
View File
@@ -93,6 +93,25 @@ enum AccountCmd {
Offline { name: Option<String> },
/// Forget one of your offline names
ForgetOffline { name: String },
/// Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape
/// PNG, the model. Kept by the launcher and given to the game when that name plays
Skin {
name: String,
/// The skin's PNG
file: Option<PathBuf>,
/// classic or slim arms
#[arg(long)]
model: Option<String>,
/// The cape's PNG
#[arg(long)]
cape: Option<PathBuf>,
/// Back to the default skin
#[arg(long)]
no_skin: bool,
/// Take the cape off
#[arg(long)]
no_cape: bool,
},
}
#[derive(Subcommand)]
@@ -963,9 +982,11 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
}
cmd.args(arg);
let started = std::time::Instant::now();
if let Some(name) = Accounts::load().name_arg() {
let accounts = Accounts::load();
if let Some(name) = accounts.name_arg() {
cmd.arg("--name").arg(name);
}
cmd.envs(accounts.look_env());
let played = attached(horizon_launcher::launch::wrap(cmd, &instance.launch));
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
eprintln!("Play time not saved: {e}");
@@ -1332,6 +1353,61 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
None => println!("The game plays offline, as the name it made up."),
}
}
AccountCmd::Skin {
name,
file,
model,
cape,
no_skin,
no_cape,
} => {
use horizon_launcher::skins::{self, Model};
let name = accounts
.offline
.iter()
.find(|n| n.eq_ignore_ascii_case(&name))
.cloned()
.ok_or_else(|| {
format!("No offline name {name}: add it with `account offline {name}`.")
})?;
let model = match model.as_deref() {
None => None,
Some("classic") => Some(Model::Classic),
Some("slim") => Some(Model::Slim),
Some(m) => return Err(format!("{m}: say classic or slim")),
};
let mut look = accounts.look(&name);
if let Some(file) = &file {
skins::keep(&skins::dir(), &name, file, false)?;
look.skin = true;
}
if let Some(file) = &cape {
skins::keep(&skins::dir(), &name, file, true)?;
look.cape = true;
}
if let Some(m) = model {
look.model = m;
}
look.skin &= !no_skin;
look.cape &= !no_cape;
accounts
.looks
.insert(name.to_ascii_lowercase(), look.clone());
accounts.save()?;
if as_json {
json(serde_json::json!({
"name": name, "skin": look.skin, "cape": look.cape, "model": look.model.name(),
}));
} else {
let say = |on: bool, what: &str| if on { what.to_string() } else { "none".into() };
println!(
"{name}: skin {} ({} arms), cape {}",
say(look.skin, "kept"),
look.model.name(),
say(look.cape, "kept")
);
}
}
AccountCmd::ForgetOffline { name } => {
accounts.remove_offline(&name)?;
accounts.save()?;
+1
View File
@@ -24,6 +24,7 @@ pub mod server_list;
pub mod servers;
pub mod shortcut;
pub mod signed;
pub mod skins;
pub mod sources;
pub mod tailscale;
pub mod telemetry;
+5
View File
@@ -32,6 +32,7 @@ mod screenshots_page;
mod scrollbar;
mod servers_page;
mod shortcut_page;
mod skin_page;
mod switch_page;
mod theme;
mod titlebar;
@@ -167,6 +168,8 @@ struct Launcher {
picker_open: bool,
/// The top bar's quick switch of who plays (`switch_page.rs`).
account_menu: bool,
/// Settings > Account's skin panel for an offline name (`skin_page.rs`).
skin: Option<skin_page::SkinPanel>,
settings: Settings,
/// The Discord, sponsor and website links: built in until links.json on main has been read.
links: backend::Links,
@@ -366,6 +369,7 @@ impl Launcher {
confirm_remove: None,
picker_open: false,
account_menu: false,
skin: None,
args_input: settings.game_args.clone(),
args_focused: false,
args_focus: cx.focus_handle(),
@@ -592,6 +596,7 @@ impl Launcher {
if let Some(name) = self.accounts.name_arg() {
cmd.arg("--name").arg(name);
}
cmd.envs(self.accounts.look_env());
// A pack's Play button plays that pack; Play plays the selected instance.
let (profile, run) = match self.starting_pack.take() {
Some(work) => {
+225
View File
@@ -0,0 +1,225 @@
//! Settings > Account's skin panel for an offline name (`horizon_launcher::skins`): the front
//! of the skin and the cape, choosing either from a PNG, and the model.
use super::*;
use gpui::RenderImage;
use horizon_launcher::skins::{self, Model};
use std::path::PathBuf;
use std::sync::Arc;
/// A baked preview, or None without one.
type Preview = Option<Arc<RenderImage>>;
#[derive(Default)]
pub(super) struct SkinPanel {
/// The offline name it is for.
pub(super) name: String,
/// The two previews, baked at the window's scale (skin, cape), or None without one.
seen: Option<(f32, Preview, Preview)>,
/// What the last choice did, or why not.
note: Option<Result<String, String>>,
}
/// Points per skin texel in the preview.
const TEXEL: f32 = 6.;
impl Launcher {
pub(super) fn open_skin(&mut self, name: String) {
self.skin = match self.skin.take() {
Some(p) if p.name == name => None,
_ => Some(SkinPanel {
name,
..SkinPanel::default()
}),
};
}
fn save_look(&mut self, change: impl FnOnce(&mut skins::Look)) -> Result<(), String> {
let Some(name) = self.skin.as_ref().map(|p| p.name.clone()) else {
return Ok(());
};
let mut accounts = Accounts::load();
let mut look = accounts.look(&name);
change(&mut look);
accounts.looks.insert(name.to_ascii_lowercase(), look);
accounts.save()?;
self.accounts = accounts;
if let Some(p) = &mut self.skin {
p.seen = None;
}
Ok(())
}
fn choose_look_file(&mut self, cape: bool, cx: &mut Context<Self>) {
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some("Choose".into()),
});
cx.spawn(async move |this, cx| {
let Ok(Ok(Some(mut paths))) = paths.await else {
return;
};
let Some(path) = paths.pop() else {
return;
};
let _ = this.update(cx, |this, cx| {
this.take_look_file(path, cape);
cx.notify();
});
})
.detach();
}
pub(super) fn take_look_file(&mut self, path: PathBuf, cape: bool) {
let Some(name) = self.skin.as_ref().map(|p| p.name.clone()) else {
return;
};
let done = skins::keep(&skins::dir(), &name, &path, cape).and_then(|()| {
self.save_look(|l| match cape {
false => l.skin = true,
true => l.cape = true,
})
});
let what = if cape { "cape" } else { "skin" };
if let Some(p) = &mut self.skin {
p.note = Some(done.map(|()| format!("{name}'s {what} is kept.")));
}
}
fn previews(&mut self) -> (Preview, Preview) {
let scale = self.scale;
let Some(panel) = &mut self.skin else {
return (None, None);
};
if let Some((s, skin, cape)) = &panel.seen {
if *s == scale {
return (skin.clone(), cape.clone());
}
}
let look = self.accounts.look(&panel.name);
let (skin_file, cape_file) = skins::files(&skins::dir(), &panel.name);
let read = |f: &PathBuf| std::fs::read(f).ok();
let skin = read(&skin_file)
.filter(|_| look.skin)
.and_then(|b| skins::read_skin(&b).ok())
.map(|png| art::bake(&skins::front(&png, look.model), 16, 32, TEXEL, scale));
let cape = read(&cape_file)
.filter(|_| look.cape)
.and_then(|b| skins::read_cape(&b).ok())
.map(|png| art::bake(&skins::cape_front(&png), 10, 16, TEXEL, scale));
panel.seen = Some((scale, skin.clone(), cape.clone()));
(skin, cape)
}
/// The panel, under the offline names while open.
pub(super) fn skin_panel(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let name = self.skin.as_ref()?.name.clone();
if !self.accounts.offline.iter().any(|n| n == &name) {
self.skin = None;
return None;
}
let look = self.accounts.look(&name);
let (skin, cape) = self.previews();
let frame = |w: f32, h: f32, image: Preview, empty: &'static str| {
div()
.flex()
.items_center()
.justify_center()
.w(px(w + 24.))
.h(px(h + 24.))
.bg(t.well)
.border_2()
.border_color(t.outline)
.child(match image {
Some(i) => gpui::img(i).w(px(w)).h(px(h)).into_any_element(),
None => text(theme::CAPTION)
.text_color(t.muted)
.child(empty)
.into_any_element(),
})
};
let weak = cx.entity().downgrade();
let model = segments(
"skin-model",
&[(Model::Classic, "Classic arms"), (Model::Slim, "Slim arms")],
look.model,
&t,
move |m, _, cx| {
let _ = weak.update(cx, |this, cx| {
let _ = this.save_look(|l| l.model = m);
cx.notify();
});
},
);
let pick = |id: &'static str, label: &'static str, cape: bool, cx: &mut Context<Self>| {
button(id, label, Kind::Secondary, &t).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.choose_look_file(cape, cx);
cx.notify();
},
))
};
let drop = |id: &'static str, label: &'static str, cape: bool, cx: &mut Context<Self>| {
button(id, label, Kind::Ghost, &t).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
let _ = this.save_look(|l| match cape {
false => l.skin = false,
true => l.cape = false,
});
cx.notify();
},
))
};
let mut skin_buttons =
div()
.flex()
.gap(px(6.))
.child(pick("skin-choose", "Choose a skin…", false, cx));
if look.skin {
skin_buttons = skin_buttons.child(drop("skin-remove", "Default skin", false, cx));
}
let mut cape_buttons =
div()
.flex()
.gap(px(6.))
.child(pick("cape-choose", "Choose a cape…", true, cx));
if look.cape {
cape_buttons = cape_buttons.child(drop("cape-remove", "No cape", true, cx));
}
let note = self.skin.as_ref().and_then(|p| p.note.clone());
Some(
plate(&t)
.flex()
.gap(px(20.))
.px(px(16.))
.py(px(14.))
.ml(px(40.))
.child(frame(16. * TEXEL, 32. * TEXEL, skin, "Default"))
.child(frame(10. * TEXEL, 16. * TEXEL, cape, "No cape"))
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.gap(px(12.))
.child(caps(format!("{name}'s skin")).text_color(t.muted))
.child(skin_buttons)
.child(model)
.child(cape_buttons)
.children(note.map(|n| match n {
Ok(said) => text(theme::CAPTION).text_color(t.muted).child(said),
Err(e) => text(theme::CAPTION).text_color(t.danger).child(e),
}))
.child(text(theme::CAPTION).text_color(t.muted).child(
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the \
launcher and given to the game when this name plays. The game \
draws the default skins for now; it uses these once it can. A \
Microsoft account's skin and cape: coming soon.",
)),
),
)
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Offline skins and capes: a PNG the player picks for an offline name, kept in the launcher's
//! `skins/` folder, with its model (classic arms or slim). The game gets them in
//! `HORIZON_OFFLINE_SKIN`, `HORIZON_OFFLINE_SKIN_MODEL` and `HORIZON_OFFLINE_CAPE` when that
//! name plays; a game that doesn't read them yet draws the default skin, as before.
//!
//! Skins are the layout every Java Edition skin uses: 64×64, or the older 64×32 (whose left arm
//! and leg are the right ones mirrored). Capes are 64×32 (or the older 22×17).
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const SKIN_ENV: &str = "HORIZON_OFFLINE_SKIN";
pub const MODEL_ENV: &str = "HORIZON_OFFLINE_SKIN_MODEL";
pub const CAPE_ENV: &str = "HORIZON_OFFLINE_CAPE";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Model {
/// Four-pixel arms ("Steve").
#[default]
Classic,
/// Three-pixel arms ("Alex").
Slim,
}
impl Model {
pub fn name(self) -> &'static str {
match self {
Model::Classic => "classic",
Model::Slim => "slim",
}
}
}
/// What an offline name wears.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Look {
#[serde(default)]
pub model: Model,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub skin: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cape: bool,
}
/// Where an offline name's files are: `skins/<name>.png` and `skins/<name>.cape.png`, the name
/// in lowercase (offline names are kept once in any case).
pub fn files(dir: &Path, name: &str) -> (PathBuf, PathBuf) {
let key = name.to_ascii_lowercase();
(
dir.join(format!("{key}.png")),
dir.join(format!("{key}.cape.png")),
)
}
pub fn dir() -> PathBuf {
crate::backend::data_dir().join("skins")
}
fn decode(bytes: &[u8]) -> Result<image::RgbaImage, String> {
image::load_from_memory_with_format(bytes, image::ImageFormat::Png)
.map(|i| i.to_rgba8())
.map_err(|_| "That isn't a PNG picture.".to_string())
}
/// A skin's picture, if it is one: 64×64 or 64×32.
pub fn read_skin(bytes: &[u8]) -> Result<image::RgbaImage, String> {
let png = decode(bytes)?;
match png.dimensions() {
(64, 64) | (64, 32) => Ok(png),
(w, h) => Err(format!(
"A skin is 64×64 (or the older 64×32) pixels; this one is {w}×{h}."
)),
}
}
/// A cape's picture, if it is one: 64×32 or 22×17.
pub fn read_cape(bytes: &[u8]) -> Result<image::RgbaImage, String> {
let png = decode(bytes)?;
match png.dimensions() {
(64, 32) | (22, 17) => Ok(png),
(w, h) => Err(format!(
"A cape is 64×32 (or the older 22×17) pixels; this one is {w}×{h}."
)),
}
}
/// A part of the skin copied to the front view: from (x, y), its size, to (x, y), mirrored.
type Part = (u32, u32, u32, u32, u32, u32, bool);
/// The front of the player, 16×32 texels: head, body, arms and legs, each with its outer layer
/// over it (64×64 skins have one for every part; 64×32 only for the head).
pub fn front(skin: &image::RgbaImage, model: Model) -> Vec<[u8; 4]> {
let w = 16usize;
let mut out = vec![[0u8; 4]; w * 32];
let modern = skin.height() == 64;
let arm = if model == Model::Slim { 3 } else { 4 };
let mut base: Vec<Part> = vec![
(8, 8, 8, 8, 4, 0, false), // head
(20, 20, 8, 12, 4, 8, false), // body
(44, 20, arm, 12, 4 - arm, 8, false), // right arm, on the viewer's left
(4, 20, 4, 12, 4, 20, false), // right leg
];
let mut outer: Vec<Part> = vec![(40, 8, 8, 8, 4, 0, false)];
if modern {
base.extend([
(36, 52, arm, 12, 12, 8, false),
(20, 52, 4, 12, 8, 20, false),
]);
outer.extend([
(20, 36, 8, 12, 4, 8, false),
(44, 36, arm, 12, 4 - arm, 8, false),
(52, 52, arm, 12, 12, 8, false),
(4, 36, 4, 12, 4, 20, false),
(4, 52, 4, 12, 8, 20, false),
]);
} else {
base.extend([(44, 20, arm, 12, 12, 8, true), (4, 20, 4, 12, 8, 20, true)]);
}
for (layer, parts) in [base, outer].into_iter().enumerate() {
for (fx, fy, pw, ph, tx, ty, mirrored) in parts {
for dy in 0..ph {
for dx in 0..pw {
let sx = if mirrored { fx + pw - 1 - dx } else { fx + dx };
let c = skin.get_pixel(sx, fy + dy).0;
// The base layer is opaque in game; the outer one shows where it is drawn.
if layer == 0 || c[3] > 0 {
out[(ty + dy) as usize * w + (tx + dx) as usize] = [c[0], c[1], c[2], 255];
}
}
}
}
}
out
}
/// The cape's outside, 10×16 texels.
pub fn cape_front(cape: &image::RgbaImage) -> Vec<[u8; 4]> {
let mut out = Vec::with_capacity(160);
for y in 1..17 {
for x in 1..11 {
out.push(cape.get_pixel(x, y).0);
}
}
out
}
/// Keeps `file` as `name`'s skin (or cape), checked first.
pub fn keep(dir: &Path, name: &str, file: &Path, cape: bool) -> Result<(), String> {
let bytes = std::fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?;
match cape {
false => read_skin(&bytes).map(|_| ())?,
true => read_cape(&bytes).map(|_| ())?,
}
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let (skin, cape_file) = files(dir, name);
let to = if cape { cape_file } else { skin };
std::fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))
}
/// The variables the game gets for `name`'s look.
pub fn env(dir: &Path, name: &str, look: &Look) -> Vec<(&'static str, String)> {
let (skin, cape) = files(dir, name);
let mut out = Vec::new();
if look.skin && skin.is_file() {
out.push((SKIN_ENV, skin.to_string_lossy().into_owned()));
out.push((MODEL_ENV, look.model.name().to_string()));
}
if look.cape && cape.is_file() {
out.push((CAPE_ENV, cape.to_string_lossy().into_owned()));
}
out
}
#[cfg(test)]
#[path = "skins/tests.rs"]
mod tests;
+104
View File
@@ -0,0 +1,104 @@
use super::*;
/// A skin whose every texel says where it is: red = x, green = y.
fn mapped(height: u32) -> image::RgbaImage {
image::RgbaImage::from_fn(64, height, |x, y| image::Rgba([x as u8, y as u8, 0, 255]))
}
fn png(img: &image::RgbaImage) -> Vec<u8> {
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn the_front_view_takes_each_part_from_its_place() {
let mut skin = mapped(64);
// No outer layer, except one hat texel.
for (x, y) in (0..64).flat_map(|x| (0..64).map(move |y| (x, y))) {
let outer = (40..48).contains(&x) && y < 16
|| (16..56).contains(&x) && (32..48).contains(&y)
|| (0..16).contains(&x) && (48..64).contains(&y)
|| (48..64).contains(&x) && (48..64).contains(&y);
if outer {
skin.put_pixel(x, y, image::Rgba([0, 0, 0, 0]));
}
}
skin.put_pixel(41, 9, image::Rgba([200, 0, 9, 255]));
let f = front(&skin, Model::Classic);
let at = |x: usize, y: usize| f[y * 16 + x];
assert_eq!(at(4, 0), [8, 8, 0, 255], "head front starts at (8, 8)");
assert_eq!(at(5, 1), [200, 0, 9, 255], "the hat covers it");
assert_eq!(at(4, 8), [20, 20, 0, 255], "body");
assert_eq!(at(0, 8), [44, 20, 0, 255], "right arm");
assert_eq!(
at(12, 8),
[36, 52, 0, 255],
"left arm, its own on a 64×64 skin"
);
assert_eq!(at(8, 20), [20, 52, 0, 255], "left leg");
let slim = front(&skin, Model::Slim);
assert_eq!(slim[8 * 16], [0, 0, 0, 0], "slim arms leave a column");
assert_eq!(slim[8 * 16 + 1], [44, 20, 0, 255]);
}
#[test]
fn an_old_skin_mirrors_its_right_limbs() {
let f = front(&mapped(32), Model::Classic);
let at = |x: usize, y: usize| f[y * 16 + x];
assert_eq!(
at(12, 8),
[47, 20, 0, 255],
"the left arm is the right one mirrored"
);
assert_eq!(at(15, 8), [44, 20, 0, 255]);
assert_eq!(at(8, 20), [7, 20, 0, 255]);
}
#[test]
fn only_skin_and_cape_sizes_are_kept() {
assert!(read_skin(&png(&mapped(64))).is_ok());
assert!(read_skin(&png(&mapped(32))).is_ok());
let big = image::RgbaImage::new(128, 128);
assert_eq!(
read_skin(&png(&big)).unwrap_err(),
"A skin is 64×64 (or the older 64×32) pixels; this one is 128×128."
);
assert!(read_skin(b"not a png").unwrap_err().contains("isn't a PNG"));
assert!(read_cape(&png(&mapped(32))).is_ok());
assert!(read_cape(&png(&image::RgbaImage::new(22, 17))).is_ok());
assert!(read_cape(&png(&mapped(64))).is_err());
assert_eq!(cape_front(&mapped(32))[0], [1, 1, 0, 255]);
}
#[test]
fn a_kept_look_reaches_the_game_only_with_its_files() {
let root = std::env::temp_dir().join(format!("horizon-skins-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let (good, bad) = (root.join("alex.png"), root.join("big.png"));
std::fs::write(&good, png(&mapped(64))).unwrap();
std::fs::write(&bad, png(&image::RgbaImage::new(10, 10))).unwrap();
let dir = root.join("skins");
let look = Look {
model: Model::Slim,
skin: true,
cape: true,
};
assert!(env(&dir, "Alex", &look).is_empty(), "nothing kept yet");
keep(&dir, "Alex", &good, false).unwrap();
assert!(keep(&dir, "Alex", &bad, true).is_err());
let (skin, _) = files(&dir, "ALEX");
assert_eq!(
env(&dir, "alex", &look),
[
(SKIN_ENV, skin.to_string_lossy().into_owned()),
(MODEL_ENV, "slim".to_string())
]
);
assert_eq!(
serde_json::to_string(&Look::default()).unwrap(),
r#"{"model":"classic"}"#
);
std::fs::remove_dir_all(&root).unwrap();
}
+12 -2
View File
@@ -169,6 +169,15 @@ impl Launcher {
),
);
}
let dressed = name.clone();
actions = actions.child(
button(format!("offline-skin-{i}"), "Skin…", Kind::Ghost, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
this.open_skin(dressed.clone());
cx.notify();
}),
),
);
let forget = name.clone();
actions = actions.child(
button(format!("offline-forget-{i}"), "Forget", Kind::Ghost, &t).on_click(
@@ -198,6 +207,7 @@ impl Launcher {
&t,
));
}
rows = rows.children(self.skin_panel(cx));
n += 1;
let add = div()
.flex()
@@ -218,8 +228,8 @@ impl Launcher {
Some(
div()
.child(
"Play offline under another name, and switch from the top bar. Offline \
names have no skin, and online servers don't take them.",
"Play offline under another name, and switch from the top bar. Online \
servers don't take offline names.",
)
.into_any_element(),
),