mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
10 files changed
+642
-3
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.",
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user