From b2199f5a0e45eb18cc0b24c75ef3f1b55c976c85 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:48:35 +0100 Subject: [PATCH] launcher: a skin and a cape for each offline name Settings > Account's Skin... shows the front of an offline name's skin and cape, takes a skin PNG (64x64 or 64x32) and a cape PNG (64x32 or 22x17), and picks classic or slim arms. The launcher keeps copies in skins/ and gives the game HORIZON_OFFLINE_SKIN, HORIZON_OFFLINE_SKIN_MODEL and HORIZON_OFFLINE_CAPE when that name plays. CLI and MCP: account skin. Co-Authored-By: Claude Opus 5.5 --- docs/launcher-commands.md | 15 +++ launcher/README.md | 7 ++ launcher/src/account.rs | 20 ++++ launcher/src/cli.rs | 78 ++++++++++++- launcher/src/lib.rs | 1 + launcher/src/main.rs | 5 + launcher/src/skin_page.rs | 225 ++++++++++++++++++++++++++++++++++++ launcher/src/skins.rs | 176 ++++++++++++++++++++++++++++ launcher/src/skins/tests.rs | 104 +++++++++++++++++ launcher/src/switch_page.rs | 14 ++- 10 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 launcher/src/skin_page.rs create mode 100644 launcher/src/skins.rs create mode 100644 launcher/src/skins/tests.rs diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 842a3c3c2..893bbd567 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -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 [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). diff --git a/launcher/README.md b/launcher/README.md index 93115c479..d76d8d897 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -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 ` (an account or an offline name), `account forget-offline `. +- **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 [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 diff --git a/launcher/src/account.rs b/launcher/src/account.rs index f402fe5ce..257055911 100644 --- a/launcher/src/account.rs +++ b/launcher/src/account.rs @@ -645,6 +645,9 @@ pub struct Accounts { /// (`horizon-player.txt`). #[serde(default, skip_serializing_if = "Option::is_none")] pub offline_selected: Option, + /// 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, } /// 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() { diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index 8153c7968..97f3eed09 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -93,6 +93,25 @@ enum AccountCmd { Offline { name: Option }, /// 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, + /// classic or slim arms + #[arg(long)] + model: Option, + /// The cape's PNG + #[arg(long)] + cape: Option, + /// 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()?; diff --git a/launcher/src/lib.rs b/launcher/src/lib.rs index 96951905f..4d8cfaaf3 100644 --- a/launcher/src/lib.rs +++ b/launcher/src/lib.rs @@ -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; diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 1496dbe59..5532b468d 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -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, 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) => { diff --git a/launcher/src/skin_page.rs b/launcher/src/skin_page.rs new file mode 100644 index 000000000..00e977216 --- /dev/null +++ b/launcher/src/skin_page.rs @@ -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>; + +#[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>, +} + +/// 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) { + 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) -> Option
{ + 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| { + 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| { + 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.", + )), + ), + ) + } +} diff --git a/launcher/src/skins.rs b/launcher/src/skins.rs new file mode 100644 index 000000000..c80cc9b90 --- /dev/null +++ b/launcher/src/skins.rs @@ -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/.png` and `skins/.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::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 { + 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 { + 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 = 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 = 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; diff --git a/launcher/src/skins/tests.rs b/launcher/src/skins/tests.rs new file mode 100644 index 000000000..6ce0473c5 --- /dev/null +++ b/launcher/src/skins/tests.rs @@ -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 { + 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(); +} diff --git a/launcher/src/switch_page.rs b/launcher/src/switch_page.rs index 3b6bdac14..a29d21044 100644 --- a/launcher/src/switch_page.rs +++ b/launcher/src/switch_page.rs @@ -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(), ),