Skip to content

Commit

Permalink
Skin selector & save local skin
Browse files Browse the repository at this point in the history
  • Loading branch information
solstice23 committed Sep 6, 2024
1 parent 4432993 commit 76de6e2
Show file tree
Hide file tree
Showing 13 changed files with 332 additions and 23 deletions.
1 change: 1 addition & 0 deletions src/assets/simple-skin/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/assets/simple-skin/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/simple-skin/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/simple-skin/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/simple-skin/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/simple-skin/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 60 additions & 7 deletions src/contexts/SkinContext.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createContext, useCallback, useLayoutEffect, useState } from "react";
import { createContext, useCallback, useEffect, useLayoutEffect, useState } from "react";
import { parsePresetSkin } from "../parser/SkinParser";
import { Assets } from "pixi.js";
import transparentSVG from "../assets/transparent.svg?base64";
import { saveLocalSkin, getAllLocalSkins, getLocalSkin, deleteLocalSkin } from '../utils/LocalSkinStorage';

export const SkinContext = createContext(null);

Expand All @@ -13,12 +14,16 @@ const loadTransparentTexture = async () => {
}

export const SKinProvider = ({children}) => {
const [skinName, setSkinName] = useState(null);
const [skinID, setSkinID] = useState(null); // Skin ID is either ^default-[a-z]$ or a hash
const [skinName, setSkinName] = useState(null); // TOOD: Delete this, use skin.name instead
const [skinID, setSkinID] = useState(null); // Skin ID is either ^default-[a-z]$ or ^custom- + a slugified name
const [skin, setSkin] = useState(null);
const [skinAssets, setSkinAssets] = useState(null);

const [localSkins, setLocalSkins] = useState([]);


const loadSkin = async (skin, skinID, skinName) => {
console.log("all local skins", await getAllLocalSkins());
console.log("loaded skin", skinName, skin);

setSkin(skin);
Expand All @@ -32,7 +37,8 @@ export const SKinProvider = ({children}) => {
// Load skin as PIXI textures
const keys = Object.keys(skin);
const textures = {};
await Promise.all([...keys.map(key => new Promise(async (resolve, reject) => {
await Promise.all([...keys.filter(key => typeof(skin[key]) == "string" && skin[key].startsWith("blob:")).map(key => new Promise(async (resolve, reject) => {
// TODO: Dont use blob urls, use base64 strings everywhere
const blobUrl = skin[key];
const blob = await fetch(blobUrl).then(res => res.blob());
const base64 = await (new Promise((resolve, reject) => {
Expand All @@ -56,6 +62,28 @@ export const SKinProvider = ({children}) => {
})]);
setSkinAssets(textures);
console.log("loaded skin assets", textures);

// save perference
localStorage.setItem("skinID", skinID);
}

const loadExternalSkin = async (skin) => {
const skinID = "custom-" + skin.name.replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').toLowerCase();
await loadSkin(skin, skinID, skin.name);
const localSkins = await getAllLocalSkins();
if (localSkins.find(skin => skin.id == skinID)) {
return;
}
await saveLocalSkin(skin, skinID);
setLocalSkins([...localSkins, {id: skinID, skin}]);
}

const loadLocalSkin = async (id) => {
const skin = await getLocalSkin(id);
if (skin) {
console.log("loading local skin", id, skin, typeof skin, Object.keys(skin));
await loadSkin(skin, id, skin.name);
}
}

const loadPresetSkin = useCallback(async (id) => {
Expand All @@ -68,26 +96,51 @@ export const SKinProvider = ({children}) => {
}
}, []);

const deleteSkin = async (id) => {
loadPresetSkin("default-classic");
await deleteLocalSkin(id);
const localSkins = await getAllLocalSkins();
setLocalSkins(localSkins);
}

useLayoutEffect(() => {
// Load default skin
(async () => {
console.log("loading default skin");
await loadPresetSkin("default-classic");
const preferedSkinID = localStorage.getItem("skinID") || "default-classic";
if (preferedSkinID.startsWith("default-")) {
loadPresetSkin(preferedSkinID);
} else {
loadLocalSkin(preferedSkinID);
}
//await loadPresetSkin("default-classic");
//await loadPresetSkin("default-simple");
})();
}, []);

useEffect(() => {
(async () => {
const localSkins = await getAllLocalSkins();
setLocalSkins(localSkins);
})();
}, []);


return (
<SkinContext.Provider value={{
skin,
loadSkin,
loadPresetSkin,
loadExternalSkin,
loadLocalSkin,
skinAssets,
skinName,
loadPresetSkin,
skinID,
localSkins,
deleteSkin
}}>
{ skin && <SkinCSSLayer skin={skin} /> }
{children}
{ children }
</SkinContext.Provider>
)
}
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useZipLoader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import { parseSkinFromZipFile } from '../parser/SkinParser';

export default function useZipLoader() {
const loadMapPack = useContext(MapPackContext).loadMapPack;
const loadSkin = useContext(SkinContext).loadSkin;
const loadExternalSkin = useContext(SkinContext).loadExternalSkin;

const loadZip = useCallback(async (file) => {
const fileName = file.name;
const { zipFile, type } = await parseZip(file);
if (type == "mapPack") {
loadMapPack(await parseMapPackFromZipFile(zipFile));
} else if (type == "skin") {
loadSkin(await parseSkinFromZipFile(zipFile));
loadExternalSkin(await parseSkinFromZipFile(zipFile, fileName));
} else {
console.log("Unknown zip type");
}
Expand Down
1 change: 1 addition & 0 deletions src/modules/Main/Playfield/ObjectsCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ class Fruit {
const spriteFilters = [], overlayFilters = [];
// Hyperfruit: red outline
if (this.obj.hyperDashTarget) {
// TODO: Some skins (default-simple) only have overlay, so we need to add the filters to overlay as well
spriteFilters.push(...[
new OutlineFilter(1.75 / 512 * this.manager.width, 0xff0000),
new GlowFilter({
Expand Down
53 changes: 47 additions & 6 deletions src/modules/Navbar/SettingsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ export function SettingsPanel () {
useLegacyDOMRenderer, setUseLegacyDOMRenderer,
} = useContext(SettingsContext);

const {
skinName, skinID, loadPresetSkin
} = useContext(SkinContext);

const [open, setOpen] = useState(false);

const mapPack = useContext(MapPackContext).mapPack;
Expand Down Expand Up @@ -207,13 +203,58 @@ function Mod({ label, acronym, description, semiSelected, value, onChange }) {
)
}

// TODO: Seperate skin selector into a new file

function SkinSelector() {
const {
skinID, loadPresetSkin, loadLocalSkin, localSkins, deleteSkin
} = useContext(SkinContext);

return (
<div className="skin-selector">
<div className="skin-selector-title">
Skin
</div>
<div className="skin-selector-content">
<div className="skin-selector-label">Skin</div>
<div className="skin-selector-value">Default</div>
<Skin id="default-classic" name="Default - Classic" onSelect={() => loadPresetSkin("default-classic")} selected={skinID === "default-classic"} />
<Skin id="default-simple" name="Default - Simple" onSelect={() => loadPresetSkin("default-simple")} selected={skinID === "default-simple"} />
{
localSkins.map((skin) => (
<Skin
key={skin.id}
id={skin.id}
name={skin.skin.name}
onSelect={() => loadLocalSkin(skin.id)}
selected={skinID === skin.id}
canDelete
onDelete={() => {
deleteSkin(skin.id);
}}
/>
))
}
</div>
</div>
)
}

function Skin({ id, name, onSelect, selected, canDelete = false, onDelete }) {
return (
<div
className={clsx("skin-item", {selected})}
onClick={onSelect}
title={name}
>
<div className="skin-item-name">{name}</div>
{
canDelete &&
<div className="delete-button" role="button" onClick={(e) => {
e.stopPropagation();
onDelete();
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z"/></svg>
</div>
}
</div>
)
}
73 changes: 71 additions & 2 deletions src/modules/Navbar/SettingsPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,76 @@
pointer-events: all;
}
}


}


.skin-selector {
.skin-selector-title {
font-size: 1.05em;
font-weight: 500;
padding: 10px 20px;
}
.skin-item {
display: flex;
flex-direction: row;
align-items: center;
font-size: 0.9em;
height: 36px;
padding: 5px 20px;
padding-right: 10px;
transition: background-color 0.2s;
cursor: pointer;
.skin-item-name {
flex: 1;
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:before {
content: '';
display: block;
width: 10px;
height: 10px;
margin-right: 12px;
border-radius: 100px;
border: 1.5px solid var(--accent-color);
filter: brightness(1.05);
transition: background-color 0.2s, filter 0.2s;
}
&.selected {
&:before {
background-color: var(--accent-color);
filter: drop-shadow(0 0 4px var(--accent-color));
}
}
.delete-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 100px;
background-color: transparent;
transition: background-color 0.2s;
svg {
width: 16px;
height: 16px;
fill: currentColor;
opacity: 0;
transition: opacity 0.2s;
}
&:hover {
svg {
opacity: 1 !important;
}
}
}
&:hover {
background-color: var(--navbar-color-light-hover);
.delete-button svg {
opacity: 0.5;
}
}
}
}
22 changes: 17 additions & 5 deletions src/parser/SkinParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const imgFileToBlobUrl = async (imgFile, mimeType = "image/png") => {
}


export async function parseSkinFromZipFile(zipFile) {
export async function parseSkinFromZipFile(zipFile, name = null) {
console.log('parsing skin from zip file', zipFile);
const skin = {};
for (const filename of skinFilenames) {
Expand All @@ -56,15 +56,17 @@ export async function parseSkinFromZipFile(zipFile) {
}
}

// read combo colours from skin.ini
// Read info from skin.ini
const skinIni = (await (async () => {
try {
return await zipFile["skin.ini"].async("text");
} catch (e) {
return "";
}
})()).split("\n").map(line => line.trim()).filter(line => line.startsWith("Combo"));
})()).split("\n").map(line => line.trim());


// Combo colours
let comboColours = [];

for (let i = 1; i <= 8; i++) {
Expand All @@ -84,6 +86,16 @@ export async function parseSkinFromZipFile(zipFile) {
comboColours = comboColours.map(([r, g, b]) => r * 256 * 256 + g * 256 + b);
skin.comboColours = comboColours;

// Skin name
const nameLine = skinIni.find(line => line.startsWith("Name:"));
if (nameLine) {
name = nameLine.split(":")[1].trim();
}
if (!name) {
name = "Custom skin " + Math.random().toString(36).substring(7);
}
skin.name = name.trim();

return skin;
}

Expand All @@ -92,5 +104,5 @@ export async function parsePresetSkin(name) {
const base64 = (name === "classic" ? classicSkin : simpleSkin);
const buffer = atob(base64);
const zipFile = await parseZipFromBuffer(buffer);
return await parseSkinFromZipFile(zipFile);
}
return await parseSkinFromZipFile(zipFile, name);
}
Loading

0 comments on commit 76de6e2

Please sign in to comment.