Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35927cf5b | ||
|
|
580a6bc7eb | ||
|
|
ff1958614c | ||
|
|
b70fa735a2 | ||
|
|
42b3cf62e5 | ||
|
|
0e1c9250bf | ||
|
|
116fda9c27 | ||
|
|
b4daee985a |
|
|
@ -1,7 +1,7 @@
|
||||||
cask "lce-emerald-launcher" do
|
cask "lce-emerald-launcher" do
|
||||||
version "1.5.1"
|
version "1.6.1"
|
||||||
sha256 intel: "7826e105f283f22cb5784b7e0dd3c90b8fc493a636ca11333a4b867f3e1a9891",
|
sha256 intel: "dd86c3f63088585f71ab94acd7c3e3ec0b535fc06ef71e7770741e08fe60782f",
|
||||||
arm: "e1c392213e0d34546f55971a1e381b942d2c896671576a18ab1db4ff1d4270b9"
|
arm: "e2e6c50a5b0b847ed7c857ab8a4ae421fa8666096f3c9fa8bb216f21639dcfd2"
|
||||||
|
|
||||||
url "https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/download/v#{version}/LCE.Emerald.Launcher_#{version}_#{arch}.dmg"
|
url "https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/download/v#{version}/LCE.Emerald.Launcher_#{version}_#{arch}.dmg"
|
||||||
name "LCE Emerald Launcher"
|
name "LCE Emerald Launcher"
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ class LceEmeraldLauncher < Formula
|
||||||
desc "Minecraft Legacy Console Edition Launcher"
|
desc "Minecraft Legacy Console Edition Launcher"
|
||||||
homepage "https://github.com/LCE-Hub/LCE-Emerald-Launcher-cask"
|
homepage "https://github.com/LCE-Hub/LCE-Emerald-Launcher-cask"
|
||||||
url "https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/download/v#{version}/LCE.Emerald.Launcher_#{version}_amd64.AppImage"
|
url "https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/download/v#{version}/LCE.Emerald.Launcher_#{version}_amd64.AppImage"
|
||||||
sha256 "541271f927249fdfc4ada03b72110cdacaef388ed33b7d75724545145b8c412f"
|
sha256 "3f135df96014e6bf83ced13a89ba2ec4599532104e60aea97c45d1d246de62f2"
|
||||||
version "1.5.0"
|
version "1.6.1"
|
||||||
license "GPL-3.0-only"
|
license "GPL-3.0-only"
|
||||||
|
|
||||||
depends_on :linux
|
depends_on :linux
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<h1>LCE Emerald Launcher</h1>
|
<h1>LCE Emerald Launcher</h1>
|
||||||
<p><strong>FOSS cross-platform launcher for Minecraft Legacy Console Edition</strong></p>
|
<p><strong>FOSS cross-platform launcher for Minecraft Legacy Console Edition</strong></p>
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/version-1.6.0-blue?style=flat-square" alt="Version">
|
<img src="https://img.shields.io/badge/version-1.6.1-blue?style=flat-square" alt="Version">
|
||||||
<img src="https://img.shields.io/badge/license-GPL--3.0-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/license-GPL--3.0-green?style=flat-square" alt="License">
|
||||||
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20GNU/Linux-lightgrey?style=flat-square" alt="Platforms">
|
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20GNU/Linux-lightgrey?style=flat-square" alt="Platforms">
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -163,7 +163,7 @@ This repository includes a local Portage overlay under [`gentoo/`](gentoo/). Poi
|
||||||
location = /path/to/LCE-Emerald-Launcher/gentoo
|
location = /path/to/LCE-Emerald-Launcher/gentoo
|
||||||
auto-sync = no
|
auto-sync = no
|
||||||
|
|
||||||
sudo emerge games-util/emerald-legacy-launcher # 1.6.0
|
sudo emerge games-util/emerald-legacy-launcher # 1.6.1
|
||||||
# sudo emerge =games-util/emerald-legacy-launcher-9999 # live git
|
# sudo emerge =games-util/emerald-legacy-launcher-9999 # live git
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
# Bump these together when packaging a new upstream stable release.
|
# Bump these together when packaging a new upstream stable release.
|
||||||
stableVersion = "1.5.1";
|
stableVersion = "1.6.0";
|
||||||
stableSrcHash = "sha256-XDR6YCWfVeQgIokMfksakw7tqQ+2uWGFqUkLumJU0EA=";
|
stableSrcHash = "sha256-VlqBCZa42MLmJ1nUNlH7PIcLp/X7mBLCS/hm/ISiHNA=";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
overlays.default = final: _prev: {
|
overlays.default = final: _prev: {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "emerald-legacy-launcher",
|
"name": "emerald-legacy-launcher",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 59 KiB |
2
src-tauri/Cargo.lock
generated
|
|
@ -1043,7 +1043,7 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "emerald-legacy-launcher"
|
name = "emerald-legacy-launcher"
|
||||||
version = "1.6.0"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "emerald-legacy-launcher"
|
name = "emerald-legacy-launcher"
|
||||||
version = "1.6.0"
|
version = "1.6.1"
|
||||||
description = "A FOSS, cross-platform launcher for Minecraft Legacy Edition"
|
description = "A FOSS, cross-platform launcher for Minecraft Legacy Edition"
|
||||||
authors = ["Emerald Team"]
|
authors = ["Emerald Team"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ pub mod file_dialogs;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod java2lce;
|
pub mod java2lce;
|
||||||
pub mod macos_setup;
|
pub mod macos_setup;
|
||||||
|
pub mod platform;
|
||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
pub mod proxy_cmd;
|
pub mod proxy_cmd;
|
||||||
pub mod runners;
|
pub mod runners;
|
||||||
|
|
|
||||||
30
src-tauri/src/commands/platform.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlatformInfo {
|
||||||
|
pub is_linux: bool,
|
||||||
|
pub is_mac: bool,
|
||||||
|
pub is_windows: bool,
|
||||||
|
pub is_android: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_platform() -> PlatformInfo {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
PlatformInfo { is_linux: true, is_mac: false, is_windows: false, is_android: false }
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
PlatformInfo { is_linux: false, is_mac: true, is_windows: false, is_android: false }
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
PlatformInfo { is_linux: false, is_mac: false, is_windows: true, is_android: false }
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
PlatformInfo { is_linux: false, is_mac: false, is_windows: false, is_android: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ use commands::download;
|
||||||
use commands::file_dialogs;
|
use commands::file_dialogs;
|
||||||
use commands::game;
|
use commands::game;
|
||||||
use commands::macos_setup;
|
use commands::macos_setup;
|
||||||
|
use commands::platform as platform_cmd;
|
||||||
use commands::plugins;
|
use commands::plugins;
|
||||||
use commands::proxy_cmd;
|
use commands::proxy_cmd;
|
||||||
use commands::runners;
|
use commands::runners;
|
||||||
|
|
@ -75,6 +76,7 @@ pub fn run() {
|
||||||
local_port: Arc::new(Mutex::new(None)),
|
local_port: Arc::new(Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
platform_cmd::get_platform,
|
||||||
macos_setup::setup_macos_runtime,
|
macos_setup::setup_macos_runtime,
|
||||||
dlc::list_git_directory,
|
dlc::list_git_directory,
|
||||||
dlc::download_dlc_files,
|
dlc::download_dlc_files,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "LCE Emerald Launcher",
|
"productName": "LCE Emerald Launcher",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"identifier": "com.emerald.legacy",
|
"identifier": "com.emerald.legacy",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Emerald Legacy Launcher Nightly",
|
"productName": "Emerald Legacy Launcher Nightly",
|
||||||
"version": "1.6.0-nightly",
|
"version": "1.6.1-nightly",
|
||||||
"identifier": "com.emerald.legacy.nightly",
|
"identifier": "com.emerald.legacy.nightly",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
220
src/components/common/SkinEditorPreview.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { useEffect, useRef, memo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
type UVSet = Record<string, number[]>;
|
||||||
|
|
||||||
|
interface SkinEditorPreviewProps {
|
||||||
|
canvas: HTMLCanvasElement | null;
|
||||||
|
slim: boolean;
|
||||||
|
previewTick: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkinEditorPreview = memo(function SkinEditorPreview({
|
||||||
|
canvas,
|
||||||
|
slim,
|
||||||
|
previewTick,
|
||||||
|
}: SkinEditorPreviewProps) {
|
||||||
|
const mountRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clonedTexsRef = useRef<THREE.Texture[]>([]);
|
||||||
|
const requestRenderRef = useRef<(() => void) | null>(null);
|
||||||
|
const rebuildPlayerRef = useRef<((source: HTMLCanvasElement, isSlim: boolean) => void) | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mountRef.current) return;
|
||||||
|
|
||||||
|
const width = mountRef.current.clientWidth || 240;
|
||||||
|
const height = mountRef.current.clientHeight || 400;
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
|
||||||
|
camera.position.set(0, 0, 68);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
mountRef.current.innerHTML = "";
|
||||||
|
mountRef.current.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
|
||||||
|
|
||||||
|
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
dl.position.set(10, 20, 10);
|
||||||
|
scene.add(dl);
|
||||||
|
|
||||||
|
const playerGroup = new THREE.Group();
|
||||||
|
playerGroup.position.y = -1.5;
|
||||||
|
scene.add(playerGroup);
|
||||||
|
|
||||||
|
const render = () => renderer.render(scene, camera);
|
||||||
|
requestRenderRef.current = render;
|
||||||
|
|
||||||
|
const clearPlayer = () => {
|
||||||
|
while (playerGroup.children.length) {
|
||||||
|
const obj = playerGroup.children[0];
|
||||||
|
playerGroup.remove(obj);
|
||||||
|
obj.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
mats.forEach((mat) => {
|
||||||
|
if (mat.map) mat.map.dispose();
|
||||||
|
mat.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clonedTexsRef.current = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasInitRot = false;
|
||||||
|
rebuildPlayerRef.current = (source: HTMLCanvasElement, isSlim: boolean) => {
|
||||||
|
const rotX = playerGroup.rotation.x;
|
||||||
|
const rotY = playerGroup.rotation.y;
|
||||||
|
clearPlayer();
|
||||||
|
const texH = source.height || 64;
|
||||||
|
const isLegacy = texH === 32;
|
||||||
|
const armW = isSlim ? 3 : 4;
|
||||||
|
const base = new THREE.CanvasTexture(source);
|
||||||
|
base.magFilter = THREE.NearestFilter;
|
||||||
|
base.minFilter = THREE.NearestFilter;
|
||||||
|
base.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
base.needsUpdate = true;
|
||||||
|
|
||||||
|
const createFaceMaterial = (x: number, y: number, w: number, h: number, flipX = false, flipY = false) => {
|
||||||
|
const matTex = base.clone();
|
||||||
|
matTex.magFilter = THREE.NearestFilter;
|
||||||
|
matTex.minFilter = THREE.NearestFilter;
|
||||||
|
matTex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / texH);
|
||||||
|
matTex.offset.set((flipX ? x + w : x) / 64, 1 - (flipY ? y : y + h) / texH);
|
||||||
|
matTex.needsUpdate = true;
|
||||||
|
clonedTexsRef.current.push(matTex);
|
||||||
|
return new THREE.MeshLambertMaterial({ map: matTex, transparent: true, alphaTest: 0.05, side: THREE.FrontSide });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPart = (w: number, h: number, d: number, uv: UVSet, overlayUv?: UVSet, swapMats = false, isLegacyMirror = false) => {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const geo = new THREE.BoxGeometry(w, h, d);
|
||||||
|
const getMats = (uvSet: UVSet) => {
|
||||||
|
const flipX = isLegacyMirror;
|
||||||
|
//+x is the characters left from the camera, this isnt swapped by accident
|
||||||
|
return [
|
||||||
|
createFaceMaterial(swapMats ? uvSet.right[0] : uvSet.left[0], uvSet.left[1], uvSet.left[2], uvSet.left[3], flipX),
|
||||||
|
createFaceMaterial(swapMats ? uvSet.left[0] : uvSet.right[0], uvSet.right[1], uvSet.right[2], uvSet.right[3], flipX),
|
||||||
|
createFaceMaterial(uvSet.top[0], uvSet.top[1], uvSet.top[2], uvSet.top[3], flipX, true),
|
||||||
|
createFaceMaterial(uvSet.bottom[0], uvSet.bottom[1], uvSet.bottom[2], uvSet.bottom[3], flipX, true),
|
||||||
|
createFaceMaterial(uvSet.front[0], uvSet.front[1], uvSet.front[2], uvSet.front[3], flipX),
|
||||||
|
createFaceMaterial(uvSet.back[0], uvSet.back[1], uvSet.back[2], uvSet.back[3], !flipX)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
group.add(new THREE.Mesh(geo, getMats(uv)));
|
||||||
|
if (overlayUv) {
|
||||||
|
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
|
||||||
|
group.add(new THREE.Mesh(oGeo, getMats(overlayUv)));
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
|
||||||
|
const limbUv = (x: number, y: number, w = 4): UVSet => ({
|
||||||
|
top: [x + 4, y, w, 4], bottom: [x + 4 + w, y, w, 4],
|
||||||
|
right: [x, y + 4, 4, 12], front: [x + 4, y + 4, w, 12],
|
||||||
|
left: [x + 4 + w, y + 4, 4, 12], back: [x + 8 + w, y + 4, w, 12]
|
||||||
|
});
|
||||||
|
|
||||||
|
const headUv = { top: [8, 0, 8, 8], bottom: [16, 0, 8, 8], right: [0, 8, 8, 8], left: [16, 8, 8, 8], front: [8, 8, 8, 8], back: [24, 8, 8, 8] };
|
||||||
|
const hatUv = { top: [40, 0, 8, 8], bottom: [48, 0, 8, 8], right: [32, 8, 8, 8], left: [48, 8, 8, 8], front: [40, 8, 8, 8], back: [56, 8, 8, 8] };
|
||||||
|
const head = createPart(8, 8, 8, headUv, hatUv);
|
||||||
|
head.position.y = 10;
|
||||||
|
playerGroup.add(head);
|
||||||
|
|
||||||
|
const bodyUv = { top: [20, 16, 8, 4], bottom: [28, 16, 8, 4], right: [16, 20, 4, 12], left: [28, 20, 4, 12], front: [20, 20, 8, 12], back: [32, 20, 8, 12] };
|
||||||
|
const jacketUv = isLegacy ? undefined : { top: [20, 32, 8, 4], bottom: [28, 32, 8, 4], right: [16, 36, 4, 12], left: [28, 36, 4, 12], front: [20, 36, 8, 12], back: [32, 36, 8, 12] };
|
||||||
|
playerGroup.add(createPart(8, 12, 4, bodyUv, jacketUv));
|
||||||
|
|
||||||
|
const rightArm = createPart(armW, 12, 4, limbUv(40, 16, armW), isLegacy ? undefined : limbUv(40, 32, armW));
|
||||||
|
rightArm.position.set(isSlim ? -5.5 : -6, 0, 0);
|
||||||
|
playerGroup.add(rightArm);
|
||||||
|
|
||||||
|
const leftArm = createPart(armW, 12, 4, isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW), isLegacy ? undefined : limbUv(48, 48, armW), isLegacy, isLegacy);
|
||||||
|
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
|
||||||
|
playerGroup.add(leftArm);
|
||||||
|
|
||||||
|
const rightLeg = createPart(4, 12, 4, limbUv(0, 16), isLegacy ? undefined : limbUv(0, 32));
|
||||||
|
rightLeg.position.set(-2, -12, 0);
|
||||||
|
playerGroup.add(rightLeg);
|
||||||
|
|
||||||
|
const leftLeg = createPart(4, 12, 4, isLegacy ? limbUv(0, 16) : limbUv(16, 48), isLegacy ? undefined : limbUv(0, 48), isLegacy, isLegacy);
|
||||||
|
leftLeg.position.set(2, -12, 0);
|
||||||
|
playerGroup.add(leftLeg);
|
||||||
|
|
||||||
|
clonedTexsRef.current.unshift(base);
|
||||||
|
playerGroup.rotation.x = rotX;
|
||||||
|
playerGroup.rotation.y = hasInitRot ? rotY : -0.35;
|
||||||
|
hasInitRot = true;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
let isDragging = false;
|
||||||
|
let previousMousePosition = { x: 0, y: 0 };
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
isDragging = true;
|
||||||
|
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||||
|
};
|
||||||
|
const onMouseUp = () => { isDragging = false; };
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
playerGroup.rotation.y += (e.clientX - previousMousePosition.x) * 0.01;
|
||||||
|
playerGroup.rotation.x += (e.clientY - previousMousePosition.y) * 0.01;
|
||||||
|
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
camera.position.z = Math.max(40, Math.min(120, camera.position.z + e.deltaY * 0.05));
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener("mousedown", onMouseDown);
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
renderer.domElement.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!mountRef.current) return;
|
||||||
|
const w = mountRef.current.clientWidth || width;
|
||||||
|
const h = mountRef.current.clientHeight || height;
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
renderer.domElement.removeEventListener("wheel", onWheel);
|
||||||
|
clearPlayer();
|
||||||
|
renderer.dispose();
|
||||||
|
requestRenderRef.current = null;
|
||||||
|
rebuildPlayerRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvas) rebuildPlayerRef.current?.(canvas, slim);
|
||||||
|
}, [canvas, slim]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clonedTexsRef.current.forEach((t) => { t.needsUpdate = true; });
|
||||||
|
requestRenderRef.current?.();
|
||||||
|
}, [previewTick]);
|
||||||
|
|
||||||
|
return <div ref={mountRef} className="w-full h-full cursor-ew-resize" />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SkinEditorPreview;
|
||||||
|
|
@ -273,7 +273,7 @@ const SkinViewer = memo(function SkinViewer({
|
||||||
4,
|
4,
|
||||||
isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW),
|
isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW),
|
||||||
isLegacy ? undefined : limbUv(48, 48, armW),
|
isLegacy ? undefined : limbUv(48, 48, armW),
|
||||||
true,
|
isLegacy,
|
||||||
isLegacy,
|
isLegacy,
|
||||||
);
|
);
|
||||||
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
|
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
|
||||||
|
|
@ -293,7 +293,7 @@ const SkinViewer = memo(function SkinViewer({
|
||||||
4,
|
4,
|
||||||
isLegacy ? limbUv(0, 16) : limbUv(16, 48),
|
isLegacy ? limbUv(0, 16) : limbUv(16, 48),
|
||||||
isLegacy ? undefined : limbUv(0, 48),
|
isLegacy ? undefined : limbUv(0, 48),
|
||||||
true,
|
isLegacy,
|
||||||
isLegacy,
|
isLegacy,
|
||||||
);
|
);
|
||||||
leftLeg.position.set(2, -12, 0);
|
leftLeg.position.set(2, -12, 0);
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ const CreditsView = memo(function CreditsView() {
|
||||||
"Tymszn21",
|
"Tymszn21",
|
||||||
"DaPogLord",
|
"DaPogLord",
|
||||||
"turniphead",
|
"turniphead",
|
||||||
|
"ArthurDotPNG",
|
||||||
|
"CounterrAct",
|
||||||
|
"luckyduuckxd",
|
||||||
|
"PixelatedCheese1",
|
||||||
].sort(() => Math.random() - 0.5),
|
].sort(() => Math.random() - 0.5),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
@ -96,6 +100,8 @@ const CreditsView = memo(function CreditsView() {
|
||||||
role: "Founder & Maintainer",
|
role: "Founder & Maintainer",
|
||||||
members: [
|
members: [
|
||||||
{ name: "KayJann", url: "https://github.com/KayJannOnGit" },
|
{ name: "KayJann", url: "https://github.com/KayJannOnGit" },
|
||||||
|
{ name: "Architect", url: "#" },
|
||||||
|
{ name: "neoapps", url: "https://github.com/neoapps-dev" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -119,6 +125,7 @@ const CreditsView = memo(function CreditsView() {
|
||||||
},
|
},
|
||||||
{ name: "Leon", url: "https://github.com/hornyalcoholic" },
|
{ name: "Leon", url: "https://github.com/hornyalcoholic" },
|
||||||
{ name: "Criador_Mods", url: "https://github.com/CriadorMods" },
|
{ name: "Criador_Mods", url: "https://github.com/CriadorMods" },
|
||||||
|
{ name: "j-carv", url: "https://github.com/j-carv" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -277,6 +284,7 @@ const CreditsView = memo(function CreditsView() {
|
||||||
{ name: "alreadywarned", url: "#" },
|
{ name: "alreadywarned", url: "#" },
|
||||||
{ name: "andrewjcf", url: "#" },
|
{ name: "andrewjcf", url: "#" },
|
||||||
{ name: "dille", url: "#" },
|
{ name: "dille", url: "#" },
|
||||||
|
{ name: "neoapps", url: "#" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -334,7 +342,7 @@ const CreditsView = memo(function CreditsView() {
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: "50%" }}
|
initial={{ y: "50%" }}
|
||||||
animate={{ y: "-200%" }}
|
animate={{ y: "-200%" }}
|
||||||
transition={{ duration: 45, ease: "linear" }}
|
transition={{ duration: 60, ease: "linear" }}
|
||||||
className="flex flex-col items-center justify-center space-y-8 py-20"
|
className="flex flex-col items-center justify-center space-y-8 py-20"
|
||||||
>
|
>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|
|
||||||
721
src/components/views/SkinEditorView.tsx
Normal file
|
|
@ -0,0 +1,721 @@
|
||||||
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useUI, useAudio, useConfig, useSkin } from "../../context/LauncherContext";
|
||||||
|
import { useLocalStorage } from "../../hooks/useLocalStorage";
|
||||||
|
import { TauriService } from "../../services/TauriService";
|
||||||
|
import SkinEditorPreview from "../common/SkinEditorPreview";
|
||||||
|
|
||||||
|
type Tool = "pencil" | "eraser" | "eyedropper" | "fill";
|
||||||
|
|
||||||
|
interface SavedSkin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isSlim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DYE_COLORS = [
|
||||||
|
"#F9FFFE", "#9D9D97", "#474F52", "#1D1D21",
|
||||||
|
"#835432", "#B02E26", "#F9801D", "#FED83D",
|
||||||
|
"#80C71F", "#5E7C16", "#169C9C", "#3AB3DA",
|
||||||
|
"#3C44AA", "#8932B8", "#C74EBD", "#F38BAA",
|
||||||
|
"#FFE0BD", "#E0AC69", "#C68642", "#8D5524",
|
||||||
|
"#5C3317", "#B5804A", "#3C8C8F", "#4C4C4C",
|
||||||
|
];
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number) {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
return {
|
||||||
|
r: parseInt(h.slice(0, 2), 16) || 0,
|
||||||
|
g: parseInt(h.slice(2, 4), 16) || 0,
|
||||||
|
b: parseInt(h.slice(4, 6), 16) || 0,
|
||||||
|
a: Math.round(alpha * 255),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(r: number, g: number, b: number) {
|
||||||
|
return "#" + [r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsv(r: number, g: number, b: number) {
|
||||||
|
r /= 255; g /= 255; b /= 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const d = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (d !== 0) {
|
||||||
|
if (max === r) h = ((g - b) / d) % 6;
|
||||||
|
else if (max === g) h = (b - r) / d + 2;
|
||||||
|
else h = (r - g) / d + 4;
|
||||||
|
h *= 60;
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
}
|
||||||
|
return { h, s: max === 0 ? 0 : d / max, v: max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsvToRgb(h: number, s: number, v: number) {
|
||||||
|
const c = v * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = v - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) { r = c; g = x; }
|
||||||
|
else if (h < 120) { r = x; g = c; }
|
||||||
|
else if (h < 180) { g = c; b = x; }
|
||||||
|
else if (h < 240) { g = x; b = c; }
|
||||||
|
else if (h < 300) { r = x; b = c; }
|
||||||
|
else { r = c; b = x; }
|
||||||
|
return { r: Math.round((r + m) * 255), g: Math.round((g + m) * 255), b: Math.round((b + m) * 255) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLine(x0: number, y0: number, x1: number, y1: number, plot: (x: number, y: number) => void) {
|
||||||
|
const dx = Math.abs(x1 - x0);
|
||||||
|
const dy = Math.abs(y1 - y0);
|
||||||
|
const sx = x0 < x1 ? 1 : -1;
|
||||||
|
const sy = y0 < y1 ? 1 : -1;
|
||||||
|
let err = dx - dy;
|
||||||
|
let x = x0;
|
||||||
|
let y = y0;
|
||||||
|
while (true) {
|
||||||
|
plot(x, y);
|
||||||
|
if (x === x1 && y === y1) break;
|
||||||
|
const e2 = 2 * err;
|
||||||
|
if (e2 > -dy) { err -= dy; x += sx; }
|
||||||
|
if (e2 < dx) { err += dx; y += sy; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRegion(data: Uint8ClampedArray, w: number, h: number, x: number, y: number, color: { r: number; g: number; b: number; a: number }) {
|
||||||
|
const idx = (px: number, py: number) => (py * w + px) * 4;
|
||||||
|
const i = idx(x, y);
|
||||||
|
const tr = data[i], tg = data[i + 1], tb = data[i + 2], ta = data[i + 3];
|
||||||
|
if (tr === color.r && tg === color.g && tb === color.b && ta === color.a) return;
|
||||||
|
const stack = [[x, y]];
|
||||||
|
const seen = new Uint8Array(w * h);
|
||||||
|
while (stack.length) {
|
||||||
|
const popped = stack.pop();
|
||||||
|
if (!popped) break;
|
||||||
|
const [px, py] = popped;
|
||||||
|
if (px < 0 || py < 0 || px >= w || py >= h) continue;
|
||||||
|
const si = py * w + px;
|
||||||
|
if (seen[si]) continue;
|
||||||
|
const pi = idx(px, py);
|
||||||
|
if (data[pi] !== tr || data[pi + 1] !== tg || data[pi + 2] !== tb || data[pi + 3] !== ta) continue;
|
||||||
|
seen[si] = 1;
|
||||||
|
data[pi] = color.r;
|
||||||
|
data[pi + 1] = color.g;
|
||||||
|
data[pi + 2] = color.b;
|
||||||
|
data[pi + 3] = color.a;
|
||||||
|
stack.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkinEditorView = memo(function SkinEditorView() {
|
||||||
|
const { setActiveView } = useUI();
|
||||||
|
const { playPressSound, playBackSound } = useAudio();
|
||||||
|
const { animationsEnabled, username } = useConfig();
|
||||||
|
const { skinUrl, setSkinUrl, skinIsSlim, setSkinIsSlim } = useSkin();
|
||||||
|
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>("lce-custom-skins", []);
|
||||||
|
|
||||||
|
const cvsRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const gridRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const undoStack = useRef<ImageData[]>([]);
|
||||||
|
const isPainting = useRef(false);
|
||||||
|
const lastPx = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const colorRef = useRef("#8B8B8B");
|
||||||
|
const alphaRef = useRef(1);
|
||||||
|
const toolRef = useRef<Tool>("pencil");
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const satValCvsRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const hueCvsRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const isDraggingSatVal = useRef(false);
|
||||||
|
const isDraggingHue = useRef(false);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [tool, setTool] = useState<Tool>("pencil");
|
||||||
|
const [color, setColor] = useState("#8B8B8B");
|
||||||
|
const [hexValue, setHexValue] = useState("8B8B8B");
|
||||||
|
const [hsv, setHsv] = useState(() => rgbToHsv(139, 139, 139));
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [alpha, setAlpha] = useState(1);
|
||||||
|
const [slim, setSlim] = useState(skinIsSlim);
|
||||||
|
const [showGrid, setShowGrid] = useState(true);
|
||||||
|
const [previewTick, setPreviewTick] = useState(0);
|
||||||
|
const [texHeight, setTexHeight] = useState(64);
|
||||||
|
const [previewCvs, setPreviewCvs] = useState<HTMLCanvasElement | null>(null);
|
||||||
|
const [focusBtn, setFocusBtn] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setColorFromHsv = (next: { h: number; s: number; v: number }) => {
|
||||||
|
setHsv(next);
|
||||||
|
const { r, g, b } = hsvToRgb(next.h, next.s, next.v);
|
||||||
|
setColor(rgbToHex(r, g, b));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setColorFromHex = (hex: string) => {
|
||||||
|
const normalized = hex.startsWith("#") ? hex : `#${hex}`;
|
||||||
|
setColor(normalized);
|
||||||
|
const { r, g, b } = hexToRgba(normalized, 1);
|
||||||
|
setHsv(rgbToHsv(r, g, b));
|
||||||
|
};
|
||||||
|
|
||||||
|
colorRef.current = color;
|
||||||
|
alphaRef.current = alpha;
|
||||||
|
toolRef.current = tool;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHexValue(color.replace("#", "").toUpperCase());
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
|
const requestTick = () => { //dotn hit setSkinUrl from here, it would spam pck gen
|
||||||
|
if (rafRef.current != null) return;
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null;
|
||||||
|
setPreviewTick((n) => n + 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushUndo = () => {
|
||||||
|
const cvs = cvsRef.current;
|
||||||
|
const ctx = cvs?.getContext("2d");
|
||||||
|
if (!cvs || !ctx) return;
|
||||||
|
undoStack.current.push(ctx.getImageData(0, 0, cvs.width, cvs.height));
|
||||||
|
if (undoStack.current.length > 50) undoStack.current.shift();
|
||||||
|
};
|
||||||
|
|
||||||
|
const redrawGrid = useCallback(() => {
|
||||||
|
const cvs = cvsRef.current;
|
||||||
|
const grid = gridRef.current;
|
||||||
|
if (!cvs || !grid) return;
|
||||||
|
const ctx = grid.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
const scale = grid.width / cvs.width;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.clearRect(0, 0, grid.width, grid.height);
|
||||||
|
ctx.drawImage(cvs, 0, 0, grid.width, grid.height);
|
||||||
|
if (showGrid) {
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= cvs.width; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(i * scale + 0.5, 0);
|
||||||
|
ctx.lineTo(i * scale + 0.5, grid.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 0; i <= cvs.height; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, i * scale + 0.5);
|
||||||
|
ctx.lineTo(grid.width, i * scale + 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showGrid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const cvs = document.createElement("canvas");
|
||||||
|
cvsRef.current = cvs;
|
||||||
|
undoStack.current = [];
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cvs.width = 64;
|
||||||
|
cvs.height = img.height === 32 ? 32 : 64;
|
||||||
|
const ctx = cvs.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.clearRect(0, 0, cvs.width, cvs.height);
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
setTexHeight(cvs.height);
|
||||||
|
setPreviewCvs(cvs);
|
||||||
|
requestTick();
|
||||||
|
};
|
||||||
|
img.src = skinUrl || "/images/Default.png";
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [skinUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redrawGrid();
|
||||||
|
}, [showGrid, previewTick, texHeight, redrawGrid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPicker) return;
|
||||||
|
const closePicker = (e: MouseEvent) => {
|
||||||
|
if (!pickerRef.current?.contains(e.target as Node)) setShowPicker(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousedown", closePicker);
|
||||||
|
return () => window.removeEventListener("mousedown", closePicker);
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const satVal = satValCvsRef.current;
|
||||||
|
if (!satVal) return;
|
||||||
|
const ctx = satVal.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
for (let y = 0; y < satVal.height; y++) {
|
||||||
|
for (let x = 0; x < satVal.width; x++) {
|
||||||
|
const { r, g, b } = hsvToRgb(hsv.h, x / (satVal.width - 1), 1 - y / (satVal.height - 1));
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.fillRect(x, y, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hsv.h, showPicker]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hueBar = hueCvsRef.current;
|
||||||
|
if (!hueBar) return;
|
||||||
|
const ctx = hueBar.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
for (let x = 0; x < hueBar.width; x++) {
|
||||||
|
const { r, g, b } = hsvToRgb((x / (hueBar.width - 1)) * 360, 1, 1);
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.fillRect(x, 0, 1, hueBar.height);
|
||||||
|
}
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
const handleSatValPick = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = satValCvsRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const s = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
|
const v = Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height));
|
||||||
|
setColorFromHsv({ ...hsv, s, v });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHuePick = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = hueCvsRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const h = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)) * 360;
|
||||||
|
setColorFromHsv({ ...hsv, h });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGridPixel = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const grid = gridRef.current;
|
||||||
|
const cvs = cvsRef.current;
|
||||||
|
if (!grid || !cvs) return null;
|
||||||
|
const rect = grid.getBoundingClientRect();
|
||||||
|
const x = Math.floor(((e.clientX - rect.left) / rect.width) * cvs.width);
|
||||||
|
const y = Math.floor(((e.clientY - rect.top) / rect.height) * cvs.height);
|
||||||
|
if (x < 0 || y < 0 || x >= cvs.width || y >= cvs.height) return null;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const paintPixel = (x: number, y: number, erase: boolean) => {
|
||||||
|
const ctx = cvsRef.current?.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
if (erase) {
|
||||||
|
ctx.clearRect(x, y, 1, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { r, g, b, a } = hexToRgba(colorRef.current, alphaRef.current);
|
||||||
|
ctx.clearRect(x, y, 1, 1);
|
||||||
|
ctx.fillStyle = `rgba(${r},${g},${b},${a / 255})`;
|
||||||
|
ctx.fillRect(x, y, 1, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTool = (x: number, y: number) => {
|
||||||
|
const cvs = cvsRef.current;
|
||||||
|
const ctx = cvs?.getContext("2d");
|
||||||
|
if (!cvs || !ctx) return;
|
||||||
|
const currentTool = toolRef.current;
|
||||||
|
if (currentTool === "eyedropper") {
|
||||||
|
const p = ctx.getImageData(x, y, 1, 1).data;
|
||||||
|
const hex = "#" + [p[0], p[1], p[2]].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
|
setColor(hex);
|
||||||
|
setHsv(rgbToHsv(p[0], p[1], p[2]));
|
||||||
|
setAlpha(p[3] / 255);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTool === "fill") {
|
||||||
|
const img = ctx.getImageData(0, 0, cvs.width, cvs.height);
|
||||||
|
fillRegion(img.data, cvs.width, cvs.height, x, y, hexToRgba(colorRef.current, alphaRef.current));
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paintPixel(x, y, currentTool === "eraser");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGridDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const p = getGridPixel(e);
|
||||||
|
if (!p) return;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
pushUndo();
|
||||||
|
isPainting.current = toolRef.current !== "eyedropper" && toolRef.current !== "fill";
|
||||||
|
applyTool(p.x, p.y);
|
||||||
|
lastPx.current = p;
|
||||||
|
requestTick();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGridMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isPainting.current) return;
|
||||||
|
const p = getGridPixel(e);
|
||||||
|
if (!p) return;
|
||||||
|
const last = lastPx.current;
|
||||||
|
const erase = toolRef.current === "eraser";
|
||||||
|
if (last) drawLine(last.x, last.y, p.x, p.y, (x, y) => paintPixel(x, y, erase));
|
||||||
|
else paintPixel(p.x, p.y, erase);
|
||||||
|
lastPx.current = p;
|
||||||
|
requestTick();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGridUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
isPainting.current = false;
|
||||||
|
lastPx.current = null;
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
const snap = undoStack.current.pop();
|
||||||
|
const ctx = cvsRef.current?.getContext("2d");
|
||||||
|
if (!snap || !ctx) return;
|
||||||
|
ctx.putImageData(snap, 0, 0);
|
||||||
|
requestTick();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const cvs = cvsRef.current;
|
||||||
|
if (!cvs) return;
|
||||||
|
playPressSound();
|
||||||
|
const dataUrl = cvs.toDataURL("image/png");
|
||||||
|
const existing = storedSkins.find((s) => s.url === skinUrl);
|
||||||
|
if (existing) {
|
||||||
|
setStoredSkins(storedSkins.map((s) => (s.id === existing.id ? { ...s, url: dataUrl, isSlim: slim } : s)));
|
||||||
|
} else {
|
||||||
|
setStoredSkins([...storedSkins, { id: Date.now().toString(), name: "Custom Skin", url: dataUrl, isSlim: slim }]);
|
||||||
|
}
|
||||||
|
setSkinUrl(dataUrl);
|
||||||
|
setSkinIsSlim(slim);
|
||||||
|
setActiveView("skins");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const cvs = cvsRef.current;
|
||||||
|
if (!cvs) return;
|
||||||
|
playPressSound();
|
||||||
|
try {
|
||||||
|
const safeName = (username || "skin").replace(/[^a-zA-Z0-9_-]/g, "") || "skin";
|
||||||
|
const fileName = `${safeName}.png`;
|
||||||
|
const path = await TauriService.saveFileDialog("Export Skin", fileName, []);
|
||||||
|
if (!path) return;
|
||||||
|
const outPath = path.toLowerCase().endsWith(".png") ? path : `${path}.png`;
|
||||||
|
const res = await fetch(cvs.toDataURL("image/png"));
|
||||||
|
const data = new Uint8Array(await res.arrayBuffer());
|
||||||
|
await TauriService.writeBinaryFile(outPath, data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err !== "CANCELED") console.error("Failed to export skin", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (showPicker) {
|
||||||
|
setShowPicker(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playBackSound();
|
||||||
|
setActiveView("skins");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.activeElement?.tagName === "INPUT") return;
|
||||||
|
if (e.key === "b" || e.key === "B") setTool("pencil");
|
||||||
|
else if (e.key === "e" || e.key === "E") setTool("eraser");
|
||||||
|
else if (e.key === "i" || e.key === "I") setTool("eyedropper");
|
||||||
|
else if (e.key === "g" || e.key === "G") setTool("fill");
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [playBackSound, setActiveView, handleUndo, showPicker]);
|
||||||
|
|
||||||
|
const tools: { id: Tool; label: string }[] = [
|
||||||
|
{ id: "pencil", label: "Pencil" },
|
||||||
|
{ id: "eraser", label: "Eraser" },
|
||||||
|
{ id: "eyedropper", label: "Pick" },
|
||||||
|
{ id: "fill", label: "Fill" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const gridScale = 8;
|
||||||
|
const gridW = 64 * gridScale;
|
||||||
|
const gridH = texHeight * gridScale;
|
||||||
|
const rgb = hsvToRgb(hsv.h, hsv.s, hsv.v);
|
||||||
|
const pixelated: React.CSSProperties = { imageRendering: "pixelated" };
|
||||||
|
const fillBg = (src: string): React.CSSProperties => ({
|
||||||
|
backgroundImage: `url('${src}')`,
|
||||||
|
backgroundSize: "100% 100%",
|
||||||
|
imageRendering: "pixelated",
|
||||||
|
});
|
||||||
|
const slot: React.CSSProperties = {
|
||||||
|
boxShadow: "inset 2px 2px 0 #000, inset -2px -2px 0 #8b8b8b",
|
||||||
|
imageRendering: "pixelated",
|
||||||
|
};
|
||||||
|
const checker: React.CSSProperties = {
|
||||||
|
...pixelated,
|
||||||
|
backgroundImage: "repeating-conic-gradient(#3f3f3f 0% 25%, #2b2b2b 0% 50%)",
|
||||||
|
backgroundSize: "8px 8px",
|
||||||
|
inset: 3,
|
||||||
|
};
|
||||||
|
const getBtnStyle = (active: boolean) =>
|
||||||
|
fillBg(active ? "/images/button_highlighted.png" : "/images/Button_Background.png");
|
||||||
|
const sliderBg = fillBg("/images/Button_Background2.png");
|
||||||
|
const btnClass = (id: string) =>
|
||||||
|
`text-2xl mc-text-shadow ${focusBtn === id ? "text-[#FFFF55]" : "text-white"}`;
|
||||||
|
const toolClass = (active: boolean) =>
|
||||||
|
`h-10 text-lg mc-text-shadow ${active ? "text-[#FFFF55]" : "text-white"}`;
|
||||||
|
|
||||||
|
const renderToolBtn = (id: string, label: string, active: boolean, onClick: () => void) => (
|
||||||
|
<button
|
||||||
|
onMouseEnter={() => setFocusBtn(id)}
|
||||||
|
onMouseLeave={() => setFocusBtn(null)}
|
||||||
|
onClick={onClick}
|
||||||
|
className={toolClass(active || focusBtn === id)}
|
||||||
|
style={getBtnStyle(active || focusBtn === id)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSlider = (label: string, value: number, max: number, onChange: (n: number) => void) => (
|
||||||
|
<div className="relative w-full h-8 flex items-center justify-center" style={sliderBg}>
|
||||||
|
<span className="absolute z-10 text-sm text-white mc-text-shadow tracking-widest pointer-events-none">
|
||||||
|
{label} {value}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={max}
|
||||||
|
step={1}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
onMouseUp={playPressSound}
|
||||||
|
className="mc-slider-custom w-[calc(100%+8px)] h-full z-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||||
|
className="flex flex-col items-center w-full max-w-5xl h-full"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[60%]
|
||||||
|
text-center tracking-widest uppercase opacity-80 font-bold border-b-2 border-[#373737]">
|
||||||
|
Skin Editor
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0 w-full gap-4 px-4 overflow-hidden">
|
||||||
|
<div className="flex flex-col gap-2 w-44 shrink-0">
|
||||||
|
{tools.map((t) =>
|
||||||
|
renderToolBtn(t.id, t.label, tool === t.id, () => {
|
||||||
|
playPressSound();
|
||||||
|
setTool(t.id);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative" ref={pickerRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Color"
|
||||||
|
onClick={() => {
|
||||||
|
playPressSound();
|
||||||
|
setShowPicker((open) => !open);
|
||||||
|
}}
|
||||||
|
className="relative w-full h-10 overflow-hidden bg-[#8b8b8b]"
|
||||||
|
style={slot}
|
||||||
|
>
|
||||||
|
<span className="absolute" style={checker} />
|
||||||
|
<span className="absolute" style={{ inset: 3, backgroundColor: color, opacity: alpha }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showPicker && (
|
||||||
|
<div
|
||||||
|
className="absolute left-full top-0 z-50 flex flex-col w-56 ml-2 p-3 gap-2"
|
||||||
|
style={fillBg("/images/frame_background.png")}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<canvas
|
||||||
|
ref={satValCvsRef}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="w-full aspect-square cursor-crosshair"
|
||||||
|
style={slot}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
isDraggingSatVal.current = true;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
handleSatValPick(e);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => { if (isDraggingSatVal.current) handleSatValPick(e); }}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
isDraggingSatVal.current = false;
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute w-2 h-2 border-2 border-white pointer-events-none"
|
||||||
|
style={{ left: `calc(${hsv.s * 100}% - 4px)`, top: `calc(${(1 - hsv.v) * 100}% - 4px)`, boxShadow: "0 0 0 1px #000" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-6">
|
||||||
|
<canvas
|
||||||
|
ref={hueCvsRef}
|
||||||
|
width={64}
|
||||||
|
height={8}
|
||||||
|
className="w-full h-6 cursor-pointer"
|
||||||
|
style={slot}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
isDraggingHue.current = true;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
handleHuePick(e);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => { if (isDraggingHue.current) handleHuePick(e); }}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
isDraggingHue.current = false;
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute top-0 w-1 h-6 bg-white pointer-events-none"
|
||||||
|
style={{ left: `calc(${(hsv.h / 360) * 100}% - 2px)`, boxShadow: "0 0 0 1px #000" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(["r", "g", "b"] as const).map((channel) =>
|
||||||
|
renderSlider(channel.toUpperCase(), rgb[channel], 255, (n) => {
|
||||||
|
const next = { ...rgb, [channel]: n };
|
||||||
|
setColorFromHex(rgbToHex(next.r, next.g, next.b));
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-8 gap-0.5 p-0.5 bg-[#8b8b8b]"
|
||||||
|
style={{ boxShadow: "inset 2px 2px 0 #000, inset -2px -2px 0 #c6c6c6" }}
|
||||||
|
>
|
||||||
|
{DYE_COLORS.map((hex) => {
|
||||||
|
const selected = color.toLowerCase() === hex.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={hex}
|
||||||
|
title={hex}
|
||||||
|
onClick={() => {
|
||||||
|
playPressSound();
|
||||||
|
setColorFromHex(hex);
|
||||||
|
}}
|
||||||
|
className="w-full aspect-square"
|
||||||
|
style={{
|
||||||
|
...pixelated,
|
||||||
|
backgroundColor: hex,
|
||||||
|
boxShadow: selected ? "inset 0 0 0 2px #FFFF55" : "inset 1px 1px 0 #00000055, inset -1px -1px 0 #ffffff44",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center h-8 bg-black/50 focus-within:outline focus-within:outline-2 focus-within:outline-[#FFFF55]"
|
||||||
|
style={{ boxShadow: "inset 2px 2px 0 #000, inset -1px -1px 0 #555" }}
|
||||||
|
>
|
||||||
|
<span className="pl-2 text-white/50 text-sm mc-text-shadow">#</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hexValue}
|
||||||
|
maxLength={6}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const hex = e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
|
||||||
|
setHexValue(hex.toUpperCase());
|
||||||
|
if (hex.length === 6) setColorFromHex(`#${hex}`);
|
||||||
|
}}
|
||||||
|
className="w-full h-full px-1 text-sm text-white tracking-widest uppercase mc-text-shadow bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full h-10 flex items-center justify-center" style={sliderBg}>
|
||||||
|
<span className="absolute z-10 text-sm text-white mc-text-shadow tracking-widest pointer-events-none">
|
||||||
|
Alpha {Math.round(alpha * 100)}%
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={Math.round(alpha * 100)}
|
||||||
|
onChange={(e) => setAlpha(Number(e.target.value) / 100)}
|
||||||
|
onMouseUp={playPressSound}
|
||||||
|
className="mc-slider-custom w-[calc(100%+8px)] h-full z-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{renderToolBtn("slim", slim ? "Alex" : "Steve", slim, () => { playPressSound(); setSlim((v) => !v); })}
|
||||||
|
{renderToolBtn("grid", "Grid", showGrid, () => { playPressSound(); setShowGrid((v) => !v); })}
|
||||||
|
{renderToolBtn("undo", "Undo", false, () => { playPressSound(); handleUndo(); })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center min-w-0 min-h-0 mt-[-200px]">
|
||||||
|
<canvas
|
||||||
|
ref={gridRef}
|
||||||
|
width={gridW}
|
||||||
|
height={gridH}
|
||||||
|
onPointerDown={handleGridDown}
|
||||||
|
onPointerMove={handleGridMove}
|
||||||
|
onPointerUp={handleGridUp}
|
||||||
|
onPointerCancel={handleGridUp}
|
||||||
|
className="max-h-full w-auto cursor-crosshair border-2 border-[#373737]"
|
||||||
|
style={{
|
||||||
|
...pixelated,
|
||||||
|
backgroundImage: "repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%)",
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-60 h-[400px] mt-[-250px] shrink-0 self-center">
|
||||||
|
<SkinEditorPreview canvas={previewCvs} slim={slim} previewTick={previewTick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mt-3 mb-2">
|
||||||
|
{[
|
||||||
|
{ id: "save", label: "Save", onClick: handleSave },
|
||||||
|
{ id: "export", label: "Export", onClick: handleExport },
|
||||||
|
{ id: "back", label: "Back", onClick: () => { playBackSound(); setActiveView("skins"); } },
|
||||||
|
].map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
onMouseEnter={() => setFocusBtn(b.id)}
|
||||||
|
onMouseLeave={() => setFocusBtn(null)}
|
||||||
|
onClick={b.onClick}
|
||||||
|
className={`w-40 h-12 ${btnClass(b.id)}`}
|
||||||
|
style={getBtnStyle(focusBtn === b.id)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SkinEditorView;
|
||||||
|
|
@ -160,7 +160,7 @@ const SkinsView = memo(function SkinsView() {
|
||||||
];
|
];
|
||||||
const [activeCapeId, setActiveCapeId] = useState<string | null>(null);
|
const [activeCapeId, setActiveCapeId] = useState<string | null>(null);
|
||||||
|
|
||||||
const TOP_BUTTONS_COUNT = viewMode === "skin" ? 3 : 3;
|
const TOP_BUTTONS_COUNT = viewMode === "skin" ? 4 : 3;
|
||||||
const SKINS_START_INDEX = TOP_BUTTONS_COUNT;
|
const SKINS_START_INDEX = TOP_BUTTONS_COUNT;
|
||||||
const BACK_BUTTON_INDEX =
|
const BACK_BUTTON_INDEX =
|
||||||
SKINS_START_INDEX +
|
SKINS_START_INDEX +
|
||||||
|
|
@ -371,8 +371,16 @@ const SkinsView = memo(function SkinsView() {
|
||||||
if (viewMode === "skin") handleDeleteActive();
|
if (viewMode === "skin") handleDeleteActive();
|
||||||
else handleDeleteActiveCape();
|
else handleDeleteActiveCape();
|
||||||
} else if (focusIndex === 2) {
|
} else if (focusIndex === 2) {
|
||||||
|
if (viewMode === "skin") {
|
||||||
|
playPressSound();
|
||||||
|
setActiveView("skin-editor");
|
||||||
|
} else {
|
||||||
|
playPressSound();
|
||||||
|
setViewMode("skin");
|
||||||
|
}
|
||||||
|
} else if (focusIndex === 3 && viewMode === "skin") {
|
||||||
playPressSound();
|
playPressSound();
|
||||||
setViewMode(viewMode === "skin" ? "cape" : "skin");
|
setViewMode("cape");
|
||||||
} else if (focusIndex < BACK_BUTTON_INDEX) {
|
} else if (focusIndex < BACK_BUTTON_INDEX) {
|
||||||
if (viewMode === "cape") {
|
if (viewMode === "cape") {
|
||||||
const capeIdx = focusIndex - SKINS_START_INDEX;
|
const capeIdx = focusIndex - SKINS_START_INDEX;
|
||||||
|
|
@ -588,11 +596,36 @@ const SkinsView = memo(function SkinsView() {
|
||||||
{viewMode === "skin" ? "Delete Skin" : "Delete Cape"}
|
{viewMode === "skin" ? "Delete Skin" : "Delete Cape"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1"></div>
|
{viewMode === "skin" && (
|
||||||
<div className="flex justify-end z-10">
|
|
||||||
<button
|
<button
|
||||||
data-index="2"
|
data-index="2"
|
||||||
onMouseEnter={() => setFocusIndex(2)}
|
onMouseEnter={() => setFocusIndex(2)}
|
||||||
|
onClick={() => {
|
||||||
|
playPressSound();
|
||||||
|
setActiveView("skin-editor");
|
||||||
|
}}
|
||||||
|
className={`w-40 h-10 flex items-center
|
||||||
|
justify-center transition-colors text-2xl
|
||||||
|
mc-text-shadow outline-none border-none hover:text-[#FFFF55]
|
||||||
|
${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
focusIndex === 2
|
||||||
|
? "url('/images/button_highlighted.png')"
|
||||||
|
: "url('/images/Button_Background.png')",
|
||||||
|
backgroundSize: "100% 100%",
|
||||||
|
imageRendering: "pixelated",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Skin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<div className="flex justify-end z-10">
|
||||||
|
<button
|
||||||
|
data-index={viewMode === "skin" ? 3 : 2}
|
||||||
|
onMouseEnter={() => setFocusIndex(viewMode === "skin" ? 3 : 2)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
playPressSound();
|
playPressSound();
|
||||||
setViewMode(viewMode === "skin" ? "cape" : "skin");
|
setViewMode(viewMode === "skin" ? "cape" : "skin");
|
||||||
|
|
@ -600,7 +633,7 @@ const SkinsView = memo(function SkinsView() {
|
||||||
className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
|
className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
focusIndex === 2
|
focusIndex === (viewMode === "skin" ? 3 : 2)
|
||||||
? "url('/images/Button_Square_Highlighted.png')"
|
? "url('/images/Button_Square_Highlighted.png')"
|
||||||
: "url('/images/Button_Square.png')",
|
: "url('/images/Button_Square.png')",
|
||||||
backgroundSize: "100% 100%",
|
backgroundSize: "100% 100%",
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export function useDiscordRPC({
|
||||||
settings: "In Settings",
|
settings: "In Settings",
|
||||||
devtools: "Developing for LCE",
|
devtools: "Developing for LCE",
|
||||||
skins: "Changing Skins",
|
skins: "Changing Skins",
|
||||||
|
"skin-editor": "Editing a Skin",
|
||||||
workshop: "Browsing Workshop",
|
workshop: "Browsing Workshop",
|
||||||
lceonline: "Browsing Friends",
|
lceonline: "Browsing Friends",
|
||||||
"pck-editor": "Editing a PCK file",
|
"pck-editor": "Editing a PCK file",
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export const BASE_EDITIONS = [
|
||||||
url: "https://git.revela.dev/itsRevela/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
|
url: "https://git.revela.dev/itsRevela/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
|
||||||
titleImage: "/images/minecraft_title_revelations.png",
|
titleImage: "/images/minecraft_title_revelations.png",
|
||||||
supportsSlimSkins: false,
|
supportsSlimSkins: false,
|
||||||
panorama: "vanilla_tu24",
|
panorama: "vanilla_tu19",
|
||||||
logo: "/images/revelations.png",
|
logo: "/images/revelations.png",
|
||||||
},
|
},
|
||||||
/*{
|
/*{
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
import { useMemo } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
//neo: "why the fuck are you doing it in rust instead of UserAgent", well, Linux was being detected as Android SOMEHOW I DONT FUCKING KNOW HOW OH MY GOD
|
||||||
|
const DEFAULT = {
|
||||||
|
isLinux: false,
|
||||||
|
isMac: false,
|
||||||
|
isWindows: false,
|
||||||
|
isAndroid: false,
|
||||||
|
};
|
||||||
export function usePlatform() {
|
export function usePlatform() {
|
||||||
const platform = useMemo(() => {
|
const [platform, setPlatform] = useState(DEFAULT);
|
||||||
if (typeof window === 'undefined') return { isLinux: false, isMac: false, isWindows: false, isAndroid: false };
|
useEffect(() => {
|
||||||
const ua = window.navigator.userAgent.toLowerCase();
|
invoke<{
|
||||||
const plat = window.navigator.platform.toLowerCase();
|
isLinux: boolean;
|
||||||
const isAndroid = ua.includes('android');
|
isMac: boolean;
|
||||||
const isLinux = !isAndroid && (plat.includes('linux') || ua.includes('linux'));
|
isWindows: boolean;
|
||||||
const isMac = !isAndroid && (plat.includes('mac') || ua.includes('mac'));
|
isAndroid: boolean;
|
||||||
const isWindows = !isAndroid && (plat.includes('win') || ua.includes('win'));
|
}>("get_platform")
|
||||||
return { isLinux, isMac, isWindows, isAndroid };
|
.then(setPlatform)
|
||||||
|
.catch(() => setPlatform(DEFAULT));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return platform;
|
return platform;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import VersionsView from "../components/views/VersionsView";
|
||||||
import DevtoolsView from "../components/views/DevtoolsView";
|
import DevtoolsView from "../components/views/DevtoolsView";
|
||||||
import GuidesView from "../components/views/GuidesView";
|
import GuidesView from "../components/views/GuidesView";
|
||||||
import SkinsView from "../components/views/SkinsView";
|
import SkinsView from "../components/views/SkinsView";
|
||||||
|
import SkinEditorView from "../components/views/SkinEditorView";
|
||||||
import WorkshopView from "../components/views/WorkshopView";
|
import WorkshopView from "../components/views/WorkshopView";
|
||||||
import SetupView from "../components/views/SetupView";
|
import SetupView from "../components/views/SetupView";
|
||||||
import PckEditorView from "../components/views/PckEditorView";
|
import PckEditorView from "../components/views/PckEditorView";
|
||||||
|
|
@ -717,6 +718,9 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeView === "skins" && <SkinsView key="skins-view" />}
|
{activeView === "skins" && <SkinsView key="skins-view" />}
|
||||||
|
{activeView === "skin-editor" && (
|
||||||
|
<SkinEditorView key="skin-editor-view" />
|
||||||
|
)}
|
||||||
{activeView === "screenshots" && (
|
{activeView === "screenshots" && (
|
||||||
<ScreenshotsView key="screenshots-view" />
|
<ScreenshotsView key="screenshots-view" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ export class LceOnlineService {
|
||||||
private _listeners: Array<() => void> = [];
|
private _listeners: Array<() => void> = [];
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadSession();
|
this.loadSession();
|
||||||
if (this._session) {
|
|
||||||
this.refreshToken();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSessionChange(listener: () => void): () => void {
|
onSessionChange(listener: () => void): () => void {
|
||||||
|
|
@ -212,28 +209,6 @@ export class LceOnlineService {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(): Promise<boolean> {
|
|
||||||
if (!this._session) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await this.request<string>("POST", "/refreshtoken", null, AUTH_BASE_URL);
|
|
||||||
if (typeof res === "string" && res.startsWith("-")) {
|
|
||||||
const data = JSON.parse(res);
|
|
||||||
this._session.accessToken = data.token;
|
|
||||||
this._session.account = { username: data.username, displayName: data.displayName };
|
|
||||||
this.saveSession();
|
|
||||||
this._notify();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
this.logoutLocal();
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to refresh token", e);
|
|
||||||
this.logoutLocal()
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSocialLists() {
|
async getSocialLists() {
|
||||||
const res = await this.request<SocialList>("GET", "/getSocialLists", null);
|
const res = await this.request<SocialList>("GET", "/getSocialLists", null);
|
||||||
if (typeof res === "string") throw new Error(res);
|
if (typeof res === "string") throw new Error(res);
|
||||||
|
|
|
||||||