diff --git a/CHANGELOG.md b/CHANGELOG.md
index 961b8a2..68efcaa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,2 +1,20 @@
+## [0.2.0]
+還是測試版,目前還在想要新增什麼功能
+
+### 新增
+- 設定新增開起存檔及暫存資料夾的按鈕,以便使用者備份或移機
+- 設定新增版本顯示
+- 裝備染色表格,預覽當前裝備色相、飽和或亮度的變化
+- 高清化(Anime4K) 現在將會直接套用在預覽角色身上
+
+### 修改
+- 修正皇家神獸學院裝備無法載入的問題(no slot/vslot at info)
+- 修正啟用篩選染色時會導致髮型及臉型也被套用的問題
+- 修正一些鞋子渲染錯誤
+- 修正部分披風渲染時會有部件消失的問題
+- 小修改一些 UI
+- 升級一些套件
+
+
## [0.1.0]
- 首次發布測試版
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index d790e12..9c664f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@ark-ui/solid": "^3.5.0",
"@nanostores/solid": "^0.4.2",
"@pdf-lib/upng": "^1.0.1",
- "@tanstack/solid-virtual": "^3.5.1",
+ "@tanstack/solid-virtual": "^3.10.6",
"@tauri-apps/api": ">=2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "^2.0.0-rc.0",
"@tauri-apps/plugin-fs": "^2.0.0-rc.0",
@@ -25,11 +25,11 @@
"lucide-solid": "^0.394.0",
"mingcute_icon": "^2.9.4",
"modern-gif": "^2.0.3",
- "nanostores": "^0.10.3",
+ "nanostores": "^0.11.3",
"p-queue": "^8.0.1",
"pixi-filters": "^6.0.3",
"pixi-viewport": "^5.0.3",
- "pixi.js": "^8.3.3",
+ "pixi.js": "^8.3.4",
"solid-js": "^1.7.8",
"throttle-debounce": "^5.0.0",
"wasm-webp": "^0.0.2"
@@ -2299,11 +2299,11 @@
}
},
"node_modules/@tanstack/solid-virtual": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.5.1.tgz",
- "integrity": "sha512-BhDKs3DDwGvA45rAgqeN3igcV0BJMwUX9lDxPmGDg2jEnGUy/iNeV5a8WexbbmOHzQiPNOZWirRU4C0Zc7nBnA==",
+ "version": "3.10.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.10.6.tgz",
+ "integrity": "sha512-r/2HiD5TOz+v0zik9DtlalPtTdYwz2HK26tKPAK/a9UUbvggVdTYbpw1IZqDLSWWh1zsp7h7FKZhmNN3r5GSJQ==",
"dependencies": {
- "@tanstack/virtual-core": "3.5.1"
+ "@tanstack/virtual-core": "3.10.6"
},
"funding": {
"type": "github",
@@ -2314,9 +2314,9 @@
}
},
"node_modules/@tanstack/virtual-core": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.5.1.tgz",
- "integrity": "sha512-046+AUSiDru/V9pajE1du8WayvBKeCvJ2NmKPy/mR8/SbKKrqmSbj7LJBfXE+nSq4f5TBXvnCzu0kcYebI9WdQ==",
+ "version": "3.10.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.6.tgz",
+ "integrity": "sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
@@ -4849,9 +4849,9 @@
}
},
"node_modules/nanostores": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.10.3.tgz",
- "integrity": "sha512-Nii8O1XqmawqSCf9o2aWqVxhKRN01+iue9/VEd1TiJCr9VT5XxgPFbF1Edl1XN6pwJcZRsl8Ki+z01yb/T/C2g==",
+ "version": "0.11.3",
+ "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.3.tgz",
+ "integrity": "sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==",
"funding": [
{
"type": "github",
@@ -5076,9 +5076,9 @@
"integrity": "sha512-DGG7cg2vUltAiL2fanzYPLR+L6qBeoskPfbUXxN6CYKW+fkni5cF9J1t2WBTmyBnC3kVq3ATFE2KDi7zy2FY8A=="
},
"node_modules/pixi.js": {
- "version": "8.3.3",
- "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz",
- "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==",
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.4.tgz",
+ "integrity": "sha512-b5qdoHMQy79JjTiOOAH/fDiK9dLKGAoxfBwkHIdsK5XKNxsFuII2MBbktvR9pVaAmTDobDkMPDoIBFKYYpDeOg==",
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
@@ -7701,17 +7701,17 @@
}
},
"@tanstack/solid-virtual": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.5.1.tgz",
- "integrity": "sha512-BhDKs3DDwGvA45rAgqeN3igcV0BJMwUX9lDxPmGDg2jEnGUy/iNeV5a8WexbbmOHzQiPNOZWirRU4C0Zc7nBnA==",
+ "version": "3.10.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.10.6.tgz",
+ "integrity": "sha512-r/2HiD5TOz+v0zik9DtlalPtTdYwz2HK26tKPAK/a9UUbvggVdTYbpw1IZqDLSWWh1zsp7h7FKZhmNN3r5GSJQ==",
"requires": {
- "@tanstack/virtual-core": "3.5.1"
+ "@tanstack/virtual-core": "3.10.6"
}
},
"@tanstack/virtual-core": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.5.1.tgz",
- "integrity": "sha512-046+AUSiDru/V9pajE1du8WayvBKeCvJ2NmKPy/mR8/SbKKrqmSbj7LJBfXE+nSq4f5TBXvnCzu0kcYebI9WdQ=="
+ "version": "3.10.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.6.tgz",
+ "integrity": "sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw=="
},
"@tauri-apps/api": {
"version": "2.0.0-rc.0",
@@ -9688,9 +9688,9 @@
"dev": true
},
"nanostores": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.10.3.tgz",
- "integrity": "sha512-Nii8O1XqmawqSCf9o2aWqVxhKRN01+iue9/VEd1TiJCr9VT5XxgPFbF1Edl1XN6pwJcZRsl8Ki+z01yb/T/C2g=="
+ "version": "0.11.3",
+ "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.3.tgz",
+ "integrity": "sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg=="
},
"node-eval": {
"version": "2.0.0",
@@ -9849,9 +9849,9 @@
"integrity": "sha512-DGG7cg2vUltAiL2fanzYPLR+L6qBeoskPfbUXxN6CYKW+fkni5cF9J1t2WBTmyBnC3kVq3ATFE2KDi7zy2FY8A=="
},
"pixi.js": {
- "version": "8.3.3",
- "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz",
- "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==",
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.4.tgz",
+ "integrity": "sha512-b5qdoHMQy79JjTiOOAH/fDiK9dLKGAoxfBwkHIdsK5XKNxsFuII2MBbktvR9pVaAmTDobDkMPDoIBFKYYpDeOg==",
"requires": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
diff --git a/package.json b/package.json
index 2dd20c6..51be7bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "maplecharactercreator",
- "version": "0.0.0",
+ "version": "0.2.0",
"description": "",
"type": "module",
"scripts": {
@@ -17,7 +17,7 @@
"@ark-ui/solid": "^3.5.0",
"@nanostores/solid": "^0.4.2",
"@pdf-lib/upng": "^1.0.1",
- "@tanstack/solid-virtual": "^3.5.1",
+ "@tanstack/solid-virtual": "^3.10.6",
"@tauri-apps/api": ">=2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "^2.0.0-rc.0",
"@tauri-apps/plugin-fs": "^2.0.0-rc.0",
@@ -30,11 +30,11 @@
"lucide-solid": "^0.394.0",
"mingcute_icon": "^2.9.4",
"modern-gif": "^2.0.3",
- "nanostores": "^0.10.3",
+ "nanostores": "^0.11.3",
"p-queue": "^8.0.1",
"pixi-filters": "^6.0.3",
"pixi-viewport": "^5.0.3",
- "pixi.js": "^8.3.3",
+ "pixi.js": "^8.3.4",
"solid-js": "^1.7.8",
"throttle-debounce": "^5.0.0",
"wasm-webp": "^0.0.2"
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 3a30426..fbc22e6 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2158,7 +2158,7 @@ dependencies = [
[[package]]
name = "maplesalon2"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"axum",
"futures",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index df4b2ec..a6ad1bf 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "maplesalon2"
-version = "0.1.0"
+version = "0.2.0"
description = "MapleSalon2 - A tool for preview hair, face and item dye using MapleStory wz files."
authors = ["Leo"]
edition = "2021"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 1e3d567..2ca901b 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "MapleSalon2",
- "version": "0.1.0",
+ "version": "0.2.0",
"identifier": "com.maplesalon.io",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/src/components/AppContainer.tsx b/src/components/AppContainer.tsx
index e8e3a45..1fb675a 100644
--- a/src/components/AppContainer.tsx
+++ b/src/components/AppContainer.tsx
@@ -18,7 +18,7 @@ export const AppContainer = (props: AppContainerProps) => {
class={css({
position: 'relative',
mx: { base: 0, lg: 2 },
- mt: 4,
+ mt: 11,
paddingLeft: isLeftDrawerPin()
? { base: 2, lg: '{sizes.xs}' }
: { base: 2, '2xl': '{sizes.xs}' },
diff --git a/src/components/CharacterPreview/ActionSelect.tsx b/src/components/CharacterPreview/ActionSelect.tsx
index aa7c4b7..96471c7 100644
--- a/src/components/CharacterPreview/ActionSelect.tsx
+++ b/src/components/CharacterPreview/ActionSelect.tsx
@@ -3,101 +3,17 @@ import { useStore } from '@nanostores/solid';
import { $currentCharacterInfo } from '@/store/character/store';
import { $currentAction } from '@/store/character/selector';
-import { SimpleSelect, type ValueChangeDetails } from '@/components/ui/select';
+import { ActionSelect as BaseActionSelect } from '@/components/elements/ActionSelect';
-import { CharacterAction } from '@/const/actions';
-
-const options = [
- {
- label: '站立',
- value: CharacterAction.Stand1,
- },
- {
- label: '坐下',
- value: CharacterAction.Sit,
- },
- {
- label: '走路',
- value: CharacterAction.Walk1,
- },
- {
- label: '跳躍',
- value: CharacterAction.Jump,
- },
- {
- label: '飛行/游泳',
- value: CharacterAction.Fly,
- },
- {
- label: '攀爬(梯子)',
- value: CharacterAction.Ladder,
- },
- {
- label: '攀爬(繩子)',
- value: CharacterAction.Rope,
- },
- {
- label: '警戒',
- value: CharacterAction.Alert,
- },
- {
- label: '施放',
- value: CharacterAction.Heal,
- },
- {
- label: '趴下',
- value: CharacterAction.Prone,
- },
- {
- label: '趴下攻擊',
- value: CharacterAction.ProneStab,
- },
- ...[
- CharacterAction.Shoot1,
- CharacterAction.Shoot2,
- CharacterAction.ShootF,
- CharacterAction.Sit,
- CharacterAction.StabO1,
- CharacterAction.StabO2,
- CharacterAction.StabOF,
- CharacterAction.StabT1,
- CharacterAction.StabT2,
- CharacterAction.StabTF,
- CharacterAction.SwingO1,
- CharacterAction.SwingO2,
- CharacterAction.SwingO3,
- CharacterAction.SwingOF,
- CharacterAction.SwingP1,
- CharacterAction.SwingP2,
- CharacterAction.SwingPF,
- CharacterAction.SwingT1,
- CharacterAction.SwingT2,
- CharacterAction.SwingT3,
- CharacterAction.SwingTF,
- ].map((value) => ({
- label: value,
- value,
- })),
-];
+import type { CharacterAction } from '@/const/actions';
export const ActionSelect = () => {
const action = useStore($currentAction);
- function handleActionChange(details: ValueChangeDetails) {
- const firstItem = details.value?.[0];
- firstItem &&
- $currentCharacterInfo.setKey('action', firstItem as CharacterAction);
+ function handleActionChange(action: CharacterAction | undefined) {
+ action && $currentCharacterInfo.setKey('action', action);
}
return (
-
+
);
};
diff --git a/src/components/CharacterPreview/Character.tsx b/src/components/CharacterPreview/Character.tsx
index c61bd6e..39104c8 100644
--- a/src/components/CharacterPreview/Character.tsx
+++ b/src/components/CharacterPreview/Character.tsx
@@ -15,12 +15,20 @@ import {
resetUpscaleSource,
setUpscaleSource,
} from '@/store/expirement/upscale';
+import { $showUpscaledCharacter } from '@/store/trigger';
import { usePureStore } from '@/store';
import { Application } from 'pixi.js';
import { Character } from '@/renderer/character/character';
import { ZoomContainer } from '@/renderer/ZoomContainer';
+/* TEST */
+import { Anime4kFilter } from '@/renderer/filter/anime4k/Anime4kFilter';
+import {
+ PipelineType,
+ type PipelineOption,
+} from '@/renderer/filter/anime4k/const';
+
export interface CharacterViewProps {
onLoad: () => void;
onLoaded: () => void;
@@ -32,8 +40,10 @@ export const CharacterView = (props: CharacterViewProps) => {
const zoomInfo = usePureStore($previewZoomInfo);
const characterData = usePureStore(props.store);
const [isInit, setIsInit] = createSignal(false);
+ const isShowUpscale = usePureStore($showUpscaledCharacter);
let container!: HTMLDivElement;
let viewport: ZoomContainer | undefined = undefined;
+ let upscaleFilter: Anime4kFilter | undefined = undefined;
const app = new Application();
const ch = new Character(app);
@@ -79,6 +89,7 @@ export const CharacterView = (props: CharacterViewProps) => {
});
container.appendChild(app.canvas);
viewport.addChild(ch);
+
app.stage.addChild(viewport);
if (props.target === 'preview') {
setUpscaleSource(app.canvas);
@@ -95,7 +106,7 @@ export const CharacterView = (props: CharacterViewProps) => {
ch.reset();
app.destroy();
if (props.target === 'preview') {
- setUpscaleSource(app.canvas);
+ resetUpscaleSource();
}
});
@@ -116,6 +127,30 @@ export const CharacterView = (props: CharacterViewProps) => {
}
});
+ createEffect(async () => {
+ if (isInit() && viewport) {
+ if (isShowUpscale()) {
+ /* to be configurable in the future */
+ const upscalePipelines = [
+ {
+ pipeline: PipelineType.ModeBB,
+ },
+ ] as PipelineOption[];
+
+ if (!upscaleFilter) {
+ await app.renderer.anime4k.preparePipeline(
+ upscalePipelines.map((p) => p.pipeline),
+ );
+ upscaleFilter = new Anime4kFilter(upscalePipelines);
+ }
+ // upscaleFilter.updatePipeine(upscalePipelines);
+ viewport.filters = [upscaleFilter];
+ } else {
+ viewport.filters = [];
+ }
+ }
+ });
+
createEffect(() => {
const info = zoomInfo();
const scaled = info.zoom;
diff --git a/src/components/CharacterPreview/CharacterScene.tsx b/src/components/CharacterPreview/CharacterScene.tsx
index c96c023..a7a4243 100644
--- a/src/components/CharacterPreview/CharacterScene.tsx
+++ b/src/components/CharacterPreview/CharacterScene.tsx
@@ -8,15 +8,11 @@ import {
$previewCharacter,
$sceneCustomColorStyle,
} from '@/store/character/selector';
-import {
- $showPreviousCharacter,
- $showUpscaledCharacter,
-} from '@/store/trigger';
+import { $showPreviousCharacter } from '@/store/trigger';
import LoaderCircle from 'lucide-solid/icons/loader-circle';
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import { CharacterView } from './Character';
-import { UpscaleCharacter } from './UpscaleCharacter';
import { CharacterSceneSelection } from './CharacterSceneSelection';
import { ShowPreviousSwitch } from './ShowPreviousSwitch';
import { ShowUpscaleSwitch } from './ShowUpscaleSwitch';
@@ -31,7 +27,6 @@ export const CharacterScene = () => {
const scene = useStore($currentScene);
const customColorStyle = useStore($sceneCustomColorStyle);
const isShowComparison = useStore($showPreviousCharacter);
- const isShowUpscale = useStore($showUpscaledCharacter);
function handleLoad() {
setIsLoading(true);
@@ -89,9 +84,6 @@ export const CharacterScene = () => {
target="preview"
isLockInteraction={isLockInteraction()}
/>
-
-
-
diff --git a/src/components/CharacterPreview/CharacterSceneColorPicker.tsx b/src/components/CharacterPreview/CharacterSceneColorPicker.tsx
index abf1333..cf13b16 100644
--- a/src/components/CharacterPreview/CharacterSceneColorPicker.tsx
+++ b/src/components/CharacterPreview/CharacterSceneColorPicker.tsx
@@ -59,6 +59,7 @@ export const CharacterSceneColorPicker = (
defaultValue="#ffffff"
positioning={{
strategy: 'fixed',
+ placement: 'top-end'
}}
onInteractOutside={handleOutsideClick}
onValueChange={handleColorChange}
diff --git a/src/components/ToolTabPage.tsx b/src/components/ToolTabPage.tsx
index 82be668..5f9251d 100644
--- a/src/components/ToolTabPage.tsx
+++ b/src/components/ToolTabPage.tsx
@@ -3,9 +3,10 @@ import { useStore } from '@nanostores/solid';
import { $toolTab } from '@/store/toolTab';
-import { ActionTab } from './ActionTab';
-import { HairDyeTab } from './DyeTab/HairDyeTab';
-import { FaceDyeTab } from './DyeTab/FaceDyeTab';
+import { ActionTab } from './tab/ActionTab';
+import { HairDyeTab } from './tab/DyeTab/HairDyeTab';
+import { FaceDyeTab } from './tab/DyeTab/FaceDyeTab';
+import { ItemDyeTab } from './tab/ItemDyeTab';
import { ToolTab } from '@/const/toolTab';
@@ -23,6 +24,9 @@ export const ToolTabPage = () => {
+
+
+
);
};
diff --git a/src/components/ToolTabsRadioGroup.tsx b/src/components/ToolTabsRadioGroup.tsx
index b55b78f..0435c77 100644
--- a/src/components/ToolTabsRadioGroup.tsx
+++ b/src/components/ToolTabsRadioGroup.tsx
@@ -24,6 +24,10 @@ const options = [
value: ToolTab.FaceDye,
label: ToolTabNames[ToolTab.FaceDye],
},
+ {
+ value: ToolTab.ItemDye,
+ label: ToolTabNames[ToolTab.ItemDye],
+ },
];
export const ToolTabsRadioGroup = () => {
diff --git a/src/components/dialog/SettingDialog/OtherSetting/OpenFolderButton.tsx b/src/components/dialog/SettingDialog/OtherSetting/OpenFolderButton.tsx
new file mode 100644
index 0000000..58f2c84
--- /dev/null
+++ b/src/components/dialog/SettingDialog/OtherSetting/OpenFolderButton.tsx
@@ -0,0 +1,39 @@
+import type { JSX } from 'solid-js';
+import { appCacheDir, appDataDir } from '@tauri-apps/api/path';
+import { open } from '@tauri-apps/plugin-shell';
+
+import FolderIcon from 'lucide-solid/icons/folder-symlink';
+import { Button } from '@/components/ui/button';
+
+import { toaster } from '@/components/GlobalToast';
+
+export enum PathType {
+ Data = 'data',
+ Cache = 'cache',
+}
+export interface OpenFolderButtonProps {
+ type: PathType;
+ title: string;
+ children: JSX.Element;
+}
+export const OpenFolderButton = (props: OpenFolderButtonProps) => {
+ async function handleClick() {
+ const folder = await (props.type === PathType.Data
+ ? appDataDir()
+ : appCacheDir());
+ try {
+ await open(folder);
+ } catch (_) {
+ toaster.error({
+ title: '開啟路徑時發生錯誤',
+ });
+ }
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/dialog/SettingDialog/OtherSetting/index.tsx b/src/components/dialog/SettingDialog/OtherSetting/index.tsx
new file mode 100644
index 0000000..52211f7
--- /dev/null
+++ b/src/components/dialog/SettingDialog/OtherSetting/index.tsx
@@ -0,0 +1,20 @@
+import { Stack } from 'styled-system/jsx/stack';
+import { HStack } from 'styled-system/jsx/hstack';
+import { Heading } from '@/components/ui/heading';
+import { OpenFolderButton, PathType } from './OpenFolderButton';
+
+export const OtherSetting = () => {
+ return (
+
+ 其他
+
+
+ 存檔資料夾
+
+
+ 暫存資料夾
+
+
+
+ );
+};
diff --git a/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx b/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx
index fd5b938..9c4019d 100644
--- a/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx
+++ b/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx
@@ -24,7 +24,7 @@ export const UpscaleSwitch = () => {
>
實驗性高清預覽
-
+
);
diff --git a/src/components/dialog/SettingDialog/SettingFooter/index.tsx b/src/components/dialog/SettingDialog/SettingFooter/index.tsx
new file mode 100644
index 0000000..6dd6400
--- /dev/null
+++ b/src/components/dialog/SettingDialog/SettingFooter/index.tsx
@@ -0,0 +1,20 @@
+import { createSignal, onMount } from 'solid-js';
+import { getVersion } from '@tauri-apps/api/app';
+
+import { HStack } from 'styled-system/jsx';
+import { Text } from '@/components/ui/text';
+
+export const SettingFooter = () => {
+ const [version, setVersion] = createSignal();
+ onMount(async () => {
+ setVersion(await getVersion());
+ });
+
+ return (
+
+
+ 當前版本: {version()}
+
+
+ );
+};
diff --git a/src/components/dialog/SettingDialog/SettingTooltip.tsx b/src/components/dialog/SettingDialog/SettingTooltip.tsx
index 7cfdfd6..6785c21 100644
--- a/src/components/dialog/SettingDialog/SettingTooltip.tsx
+++ b/src/components/dialog/SettingDialog/SettingTooltip.tsx
@@ -1,13 +1,10 @@
-import InfoIcon from 'lucide-solid/icons/info';
-import { SimpleTooltip } from '@/components/ui/tooltip';
+import { IconTooltop, IconType } from '@/components/elements/IconTooltip';
export interface SettingTooltipProps {
tooltip: string;
}
export const SettingTooltip = (props: SettingTooltipProps) => {
return (
-
-
-
+
);
};
diff --git a/src/components/dialog/SettingDialog/index.tsx b/src/components/dialog/SettingDialog/index.tsx
index 752c79c..03c320d 100644
--- a/src/components/dialog/SettingDialog/index.tsx
+++ b/src/components/dialog/SettingDialog/index.tsx
@@ -4,6 +4,8 @@ import { SettingDialog as Dialog } from './SettingDialog';
import { WindowSetting } from './WindowSetting';
import { ThemeSetting } from './ThemeSetting';
import { RenderSetting } from './RenderSetting';
+import { OtherSetting } from './OtherSetting';
+import { SettingFooter } from './SettingFooter';
export const SettingDialog = () => {
return (
@@ -13,6 +15,8 @@ export const SettingDialog = () => {
+
+
);
diff --git a/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx b/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx
index 9b0ba07..59fdae8 100644
--- a/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx
+++ b/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx
@@ -22,8 +22,12 @@ interface EquipDrawerProps {
}
export const EquipDrawer = (props: EquipDrawerProps) => {
const isOpen = useStore($equpimentDrawerOpen);
+ const isPinned = useStore($equpimentDrawerPin);
function handleClose(_: unknown) {
+ if (isPinned()) {
+ return;
+ }
$equpimentDrawerOpen.set(false);
}
@@ -44,7 +48,11 @@ export const EquipDrawer = (props: EquipDrawerProps) => {
{props.header}
-
+
diff --git a/src/components/elements/ActionSelect.tsx b/src/components/elements/ActionSelect.tsx
new file mode 100644
index 0000000..74a39c7
--- /dev/null
+++ b/src/components/elements/ActionSelect.tsx
@@ -0,0 +1,100 @@
+import { SimpleSelect, type ValueChangeDetails } from '@/components/ui/select';
+
+import { CharacterAction } from '@/const/actions';
+
+const options = [
+ {
+ label: '站立',
+ value: CharacterAction.Stand1,
+ },
+ {
+ label: '坐下',
+ value: CharacterAction.Sit,
+ },
+ {
+ label: '走路',
+ value: CharacterAction.Walk1,
+ },
+ {
+ label: '跳躍',
+ value: CharacterAction.Jump,
+ },
+ {
+ label: '飛行/游泳',
+ value: CharacterAction.Fly,
+ },
+ {
+ label: '攀爬(梯子)',
+ value: CharacterAction.Ladder,
+ },
+ {
+ label: '攀爬(繩子)',
+ value: CharacterAction.Rope,
+ },
+ {
+ label: '警戒',
+ value: CharacterAction.Alert,
+ },
+ {
+ label: '施放',
+ value: CharacterAction.Heal,
+ },
+ {
+ label: '趴下',
+ value: CharacterAction.Prone,
+ },
+ {
+ label: '趴下攻擊',
+ value: CharacterAction.ProneStab,
+ },
+ ...[
+ CharacterAction.Shoot1,
+ CharacterAction.Shoot2,
+ CharacterAction.ShootF,
+ CharacterAction.Sit,
+ CharacterAction.StabO1,
+ CharacterAction.StabO2,
+ CharacterAction.StabOF,
+ CharacterAction.StabT1,
+ CharacterAction.StabT2,
+ CharacterAction.StabTF,
+ CharacterAction.SwingO1,
+ CharacterAction.SwingO2,
+ CharacterAction.SwingO3,
+ CharacterAction.SwingOF,
+ CharacterAction.SwingP1,
+ CharacterAction.SwingP2,
+ CharacterAction.SwingPF,
+ CharacterAction.SwingT1,
+ CharacterAction.SwingT2,
+ CharacterAction.SwingT3,
+ CharacterAction.SwingTF,
+ ].map((value) => ({
+ label: value,
+ value,
+ })),
+];
+
+export interface ActionSelectProps {
+ value: CharacterAction;
+ onValueChange: (value: CharacterAction | undefined) => void;
+}
+export const ActionSelect = (props: ActionSelectProps) => {
+ function handleActionChange(details: ValueChangeDetails) {
+ const firstItem = details.value?.[0] as CharacterAction | undefined;
+ props.onValueChange(firstItem);
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/elements/IconTooltip.tsx b/src/components/elements/IconTooltip.tsx
new file mode 100644
index 0000000..2b643b1
--- /dev/null
+++ b/src/components/elements/IconTooltip.tsx
@@ -0,0 +1,29 @@
+import { Match, Switch } from 'solid-js';
+import InfoIcon from 'lucide-solid/icons/info';
+import CircleHelpIcon from 'lucide-solid/icons/circle-help';
+import { SimpleTooltip } from '@/components/ui/tooltip';
+
+export enum IconType {
+ Info = 'info',
+ Question = 'question',
+}
+export interface IconTooltopProps {
+ tooltip: string;
+ type: IconType;
+ zIndex?: number;
+ size?: number;
+}
+export const IconTooltop = (props: IconTooltopProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/elements/LoadableEquipIcon.tsx b/src/components/elements/LoadableEquipIcon.tsx
index d6e68a0..4d1530d 100644
--- a/src/components/elements/LoadableEquipIcon.tsx
+++ b/src/components/elements/LoadableEquipIcon.tsx
@@ -64,7 +64,11 @@ export const LoadableEquipIcon = (props: LoadableEquipIconProps) => {
alignItems="center"
isLoaded={isLoaded()}
>
-
+
}>
>;
+ handleDyeClick: (data: Partial>) => void;
+ ref?: (element: HTMLImageElement) => void;
+ dyeInfo: JSX.Element;
+}
+export const DyeCharacter = (props: DyeCharacterProps) => {
+ function handleSelect() {
+ props.handleDyeClick(props.dyeData);
+ }
+ function getDyeString() {
+ return Object.entries(props.dyeData)
+ .map(([key, value]) => `${key}-${value}`)
+ .join(', ');
+ }
+
+ return (
+
+
+ {props.dyeInfo}
+
+ );
+};
+
+const CharacterItemContainer = styled('button', {
+ base: {
+ display: 'inline-block',
+ position: 'relative',
+ _hover: {
+ '& [data-part="info"]': {
+ opacity: 0.9,
+ },
+ },
+ },
+});
+
+const CharacterItemImage = styled('img', {
+ base: {
+ maxWidth: 'unset',
+ width: 'unset',
+ },
+});
+
+const DyeInfoPositioner = styled('div', {
+ base: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+});
diff --git a/src/components/tab/ItemDyeTab/DyeInfo.tsx b/src/components/tab/ItemDyeTab/DyeInfo.tsx
new file mode 100644
index 0000000..19d6a88
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeInfo.tsx
@@ -0,0 +1,56 @@
+import { For } from 'solid-js';
+import { styled } from 'styled-system/jsx/factory';
+import { css } from 'styled-system/css';
+
+import { Flex } from 'styled-system/jsx';
+import { DyeType } from '@/const/toolTab';
+
+const GradientMap = {
+ [DyeType.Hue]: 'hueConic',
+ [DyeType.Saturation]: 'saturation',
+ [DyeType.Birghtness]: 'brightness',
+};
+
+export interface DyeInfoProps {
+ dyeData: Partial>;
+}
+export const DyeInfo = (props: DyeInfoProps) => {
+ return (
+
+
+ {([key, value]) => (
+ <>
+
+ +{value}
+ >
+ )}
+
+
+ );
+};
+
+const DyeInfoPanel = styled(Flex, {
+ base: {
+ py: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'bg.default',
+ borderTopRadius: 'md',
+ boxShadow: 'md',
+ opacity: 0,
+ transition: 'opacity 0.2s',
+ fontSize: 'xs',
+ },
+});
+const ColorBlock = styled('div', {
+ base: {
+ borderRadius: 'sm',
+ w: 3,
+ h: 3,
+ display: 'inline-block',
+ },
+});
diff --git a/src/components/tab/ItemDyeTab/DyeResult.tsx b/src/components/tab/ItemDyeTab/DyeResult.tsx
new file mode 100644
index 0000000..695fc38
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeResult.tsx
@@ -0,0 +1,56 @@
+import { useStore } from '@nanostores/solid';
+
+import {
+ $isExportable,
+ $dyeResultCount,
+ $dyeResultColumnCount,
+} from '@/store/toolTab';
+
+import { HStack } from 'styled-system/jsx/hstack';
+import { Text } from '@/components/ui/text';
+import { Heading } from '@/components/ui/heading';
+import { ResultColumnCountNumberInput } from './ResultColumnCountNumberInput';
+import { DyeResultTable } from './DyeResultTable';
+import { ExportTableButton } from './ExportTableButton';
+import { ExportSeperateButton } from '../DyeTab/ExportSeperateButton';
+
+export const DyeResult = () => {
+ const isExportable = useStore($isExportable);
+ const count = useStore($dyeResultCount);
+ const columnCounts = useStore($dyeResultColumnCount);
+ const dyeCharacterRefs: HTMLImageElement[] = [];
+
+ return (
+ <>
+
+
+ 染色結果
+
+
+ 每行數量
+
+
+
+
+ 匯出表格圖
+
+
+ 匯出(.zip)
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/DyeResultTable.tsx b/src/components/tab/ItemDyeTab/DyeResultTable.tsx
new file mode 100644
index 0000000..8fe6be2
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeResultTable.tsx
@@ -0,0 +1,205 @@
+import { createEffect, For, untrack } from 'solid-js';
+import { createStore } from 'solid-js/store';
+import { useStore } from '@nanostores/solid';
+
+import {
+ $selectedEquipSubCategory,
+ $dyeResultCount,
+ $dyeRenderId,
+ $isRenderingDye,
+ $onlyShowDyeable,
+ $preserveOriginalDye,
+ $dyeTypeEnabled,
+ $dyeAction,
+ $dyeResultColumnCount,
+} from '@/store/toolTab';
+import { getEquipById } from '@/store/string';
+import {
+ $isGlobalRendererInitialized,
+ $globalRenderer,
+} from '@/store/renderer';
+import { $currentCharacterInfo } from '@/store/character/store';
+import { $totalItems } from '@/store/character/selector';
+import { deepCloneCharacterItems } from '@/store/character/utils';
+
+import { Character } from '@/renderer/character/character';
+
+import { Grid } from 'styled-system/jsx/grid';
+import { DyeInfo } from './DyeInfo';
+import { DyeCharacter } from './DyeCharacter';
+
+import { extractCanvas, getBlobFromCanvas } from '@/utils/extract';
+import { nextTick } from '@/utils/eventLoop';
+
+import { DyeType } from '@/const/toolTab';
+import { CharacterExpressions } from '@/const/emotions';
+import { updateItemHsvInfo } from '@/store/character/action';
+
+const DyePropertyMap = {
+ [DyeType.Hue]: {
+ min: 0,
+ max: 360,
+ },
+ [DyeType.Saturation]: {
+ min: -100,
+ max: 100,
+ },
+ [DyeType.Birghtness]: {
+ min: -100,
+ max: 100,
+ },
+} as const;
+
+function getActualNeedDyeCategories() {
+ const selectedEquipSubCategory = $selectedEquipSubCategory.get();
+ const totalItems = $totalItems.get();
+ const isOnlyShowDyeable = $onlyShowDyeable.get();
+ const actaulNeedDyeCategories = selectedEquipSubCategory.filter(
+ (equipSubCategory) => {
+ /* make sure the category is in current item */
+ const item = totalItems[equipSubCategory as keyof typeof totalItems];
+ if (!item) {
+ return false;
+ }
+ if (isOnlyShowDyeable) {
+ return getEquipById(item.id)?.isDyeable;
+ }
+ return true;
+ },
+ );
+ return actaulNeedDyeCategories;
+}
+
+export interface DyeResultTableProps {
+ refs?: HTMLImageElement[];
+}
+export function DyeResultTable(props: DyeResultTableProps) {
+ const [state, setState] = createStore({
+ results: [] as { url: string; info: Partial> }[],
+ });
+ let testIndex = 0;
+ const renderId = useStore($dyeRenderId);
+ const isInit = useStore($isGlobalRendererInitialized);
+ const gridColumns = useStore($dyeResultColumnCount);
+ const character = new Character();
+
+ function handleRef(i: number) {
+ return (element: HTMLImageElement) => {
+ if (!props.refs) {
+ return;
+ }
+ props.refs[i] = element;
+ };
+ }
+
+ function handleDyeClick(data: Partial>) {
+ const actualneedDyeCategories = getActualNeedDyeCategories();
+ for (const equipSubCategory of actualneedDyeCategories) {
+ for (const [dyeType, value] of Object.entries(data)) {
+ updateItemHsvInfo(equipSubCategory, dyeType as DyeType, value);
+ }
+ }
+ }
+
+ function cleanUpStore() {
+ const currentResults = untrack(() => state.results);
+ for (const result of currentResults) {
+ URL.revokeObjectURL(result.url);
+ }
+ testIndex = 0;
+ setState('results', []);
+ }
+
+ createEffect(async () => {
+ if (renderId() && isInit()) {
+ $isRenderingDye.set(true);
+ cleanUpStore();
+ await nextTick();
+ // return;
+
+ const app = $globalRenderer.get();
+ const currentCharacterInfo = $currentCharacterInfo.get();
+ const characterData = {
+ frame: 0,
+ isAnimating: false,
+ action: $dyeAction.get(),
+ expression: CharacterExpressions.Default,
+ earType: currentCharacterInfo.earType,
+ handType: currentCharacterInfo.handType,
+ items: deepCloneCharacterItems($totalItems.get()),
+ };
+ const actualneedDyeCategories = getActualNeedDyeCategories();
+ const dyeResultCount = $dyeResultCount.get();
+ const preserveOriginalDye = $preserveOriginalDye.get();
+ const dyeTypeEnabled = $dyeTypeEnabled.get();
+ if (
+ !dyeTypeEnabled ||
+ actualneedDyeCategories.length === 0 ||
+ dyeResultCount < 1
+ ) {
+ $isRenderingDye.set(false);
+ return;
+ }
+ /* reset dye when chose not preserve original dye */
+ if (!preserveOriginalDye) {
+ for (const equipSubCategory of actualneedDyeCategories) {
+ if (characterData.items[equipSubCategory]) {
+ characterData.items[equipSubCategory].hue = 0;
+ characterData.items[equipSubCategory].saturation = 0;
+ characterData.items[equipSubCategory].brightness = 0;
+ }
+ }
+ }
+ /* load once first */
+ await character.update(characterData);
+
+ /* start generate */
+ const dyeRangeConfig = DyePropertyMap[dyeTypeEnabled];
+ const step = (dyeRangeConfig.max - dyeRangeConfig.min) / dyeResultCount;
+ for (let i = 0; i < dyeResultCount; i++) {
+ const dyeNumber = Math.floor(dyeRangeConfig.min + step * i);
+ for (const equipSubCategory of actualneedDyeCategories) {
+ if (characterData.items[equipSubCategory]) {
+ characterData.items[equipSubCategory][dyeTypeEnabled] = dyeNumber;
+ }
+ }
+ await character.update(characterData);
+ /* give some time */
+ await nextTick();
+
+ const canvas = extractCanvas(character, app.renderer);
+ const blob = await getBlobFromCanvas(canvas as HTMLCanvasElement);
+ if (blob) {
+ const url = URL.createObjectURL(blob);
+ setState('results', testIndex, {
+ url,
+ info: { [dyeTypeEnabled]: dyeNumber },
+ });
+ testIndex++;
+ }
+ }
+ $isRenderingDye.set(false);
+ }
+ });
+ return (
+
+
+ {(result, i) => (
+ }
+ />
+ )}
+
+
+ );
+}
diff --git a/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
new file mode 100644
index 0000000..d00450a
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
@@ -0,0 +1,61 @@
+import { styled } from 'styled-system/jsx/factory';
+import { useStore } from '@nanostores/solid';
+
+import { $dyeTypeEnabled, toggleDyeConfigEnabled } from '@/store/toolTab';
+
+import * as RadioGroup from '@/components/ui/radioGroup';
+
+import { DyeType } from '@/const/toolTab';
+
+export const DyeTypeRadioGroup = () => {
+ const dyeTypeEnabled = useStore($dyeTypeEnabled);
+
+ const handleValueChange = (value: RadioGroup.ValueChangeDetails) => {
+ toggleDyeConfigEnabled(value.value as DyeType, true);
+ };
+
+ return (
+
+
+
+
+ 色相
+
+
+
+
+
+
+
+ 飽和度
+
+
+
+
+
+
+
+ 亮度
+
+
+
+
+
+ );
+};
+
+const ColorBlock = styled('div', {
+ base: {
+ display: 'inline-block',
+ w: 3,
+ h: 3,
+ ml: 2,
+ borderRadius: 'sm',
+ boxShadow: 'md',
+ },
+});
diff --git a/src/components/tab/ItemDyeTab/ExportTableButton.tsx b/src/components/tab/ItemDyeTab/ExportTableButton.tsx
new file mode 100644
index 0000000..628b63e
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ExportTableButton.tsx
@@ -0,0 +1,79 @@
+import type { JSX } from 'solid-js';
+
+import { Button } from '@/components/ui/button';
+
+import { downloadCanvas } from '@/utils/download';
+import { toaster } from '@/components/GlobalToast';
+
+const TABLE_COL_GAP = 2;
+const TABLE_ROW_GAP = 2;
+
+export interface ExportTableButtonProps {
+ images: HTMLImageElement[];
+ imageCounts: number;
+ columnCounts: number;
+ fileName: string;
+ children: JSX.Element;
+ disabled?: boolean;
+}
+export const ExportTableButton = (props: ExportTableButtonProps) => {
+ async function handleClick() {
+ const validImageCounts = props.images.filter((img) => img?.src).length;
+ const colCounts = props.columnCounts;
+ const rowCounts = Math.ceil(props.imageCounts / colCounts);
+ const isAllImagesLoaded = props.imageCounts === validImageCounts;
+ if (!isAllImagesLoaded) {
+ toaster.error({
+ title: '圖片尚未載入完畢',
+ });
+ return;
+ }
+ const canvas = document.createElement('canvas');
+ const tableImageItem = props.images[0];
+ const tableColWidth = tableImageItem.width;
+
+ const totalWidth = (tableColWidth + TABLE_COL_GAP) * colCounts;
+ const totalHeight = (tableImageItem.height + TABLE_ROW_GAP) * rowCounts;
+
+ canvas.width = totalWidth;
+ canvas.height = totalHeight;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ toaster.error({
+ title: '匯出失敗,無法建立 Canvas',
+ });
+ return;
+ }
+ let startY = 0;
+ for (let y = 0; y < rowCounts; y++) {
+ for (let x = 0; x < colCounts; x++) {
+ const index = y * colCounts + x;
+ const image = props.images[index];
+ if (!image) {
+ continue;
+ }
+ ctx.drawImage(image, x * (tableColWidth + TABLE_COL_GAP), startY);
+ }
+ startY += tableImageItem.height + TABLE_ROW_GAP;
+ }
+ try {
+ await downloadCanvas(canvas, props.fileName);
+ } catch (_) {
+ toaster.error({
+ title: '匯出失敗,Canvas 無法建立 Blob',
+ });
+ }
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/ItemDyeTabTitle.tsx b/src/components/tab/ItemDyeTab/ItemDyeTabTitle.tsx
new file mode 100644
index 0000000..a1c0b1b
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ItemDyeTabTitle.tsx
@@ -0,0 +1,16 @@
+import { HStack } from 'styled-system/jsx/hstack';
+import { Heading } from '@/components/ui/heading';
+import { OnlyDyeableSwitch } from './OnlyDyeableSwitch';
+import { PreserveDyeSwitch } from './PreserveDyeSwitch';
+
+export const ItemDyeTabTitle = () => {
+ return (
+
+
+ 裝備染色表
+
+
+
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
new file mode 100644
index 0000000..dbccf99
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
@@ -0,0 +1,134 @@
+import { Show, createMemo, splitProps } from 'solid-js';
+import { useStore } from '@nanostores/solid';
+import { styled } from 'styled-system/jsx/factory';
+
+import { createEquipItemByCategory } from '@/store/character/selector';
+import { getEquipById } from '@/store/string';
+
+import CheckIcon from 'lucide-solid/icons/check';
+import type { ItemProps } from '@/components/ui/toggleGroup';
+import { LoadableEquipIcon } from '@/components/elements/LoadableEquipIcon';
+import { EllipsisText } from '@/components/ui/ellipsisText';
+import {
+ EquipItemIcon,
+ EquipItemName,
+ EquipItemInfo,
+} from '@/components/drawer/CurrentEquipmentDrawer/EquipItem';
+
+import type { EquipSubCategory } from '@/const/equipments';
+
+type ItemChildProps = NonNullable<
+ Parameters>[0]>[0]
+>;
+export interface NeedDyeItemProps extends ItemChildProps {
+ category: EquipSubCategory;
+ onlyShowDyeable?: boolean;
+}
+export const NeedDyeItem = (props: NeedDyeItemProps) => {
+ const [ownProps, buttonProps] = splitProps(props, [
+ 'category',
+ 'onlyShowDyeable',
+ 'class',
+ ]);
+ const item = useStore(createEquipItemByCategory(ownProps.category));
+
+ const equipInfo = createMemo(() => {
+ const id = item()?.id;
+ if (!id) {
+ return;
+ }
+ return getEquipById(id);
+ });
+
+ return (
+
+
+ {(item) => (
+
+
+
+
+
+
+
+
+ {item().name}
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+const SelectableContainer = styled('button', {
+ base: {
+ display: 'grid',
+ py: '1',
+ pl: '1',
+ pr: '2',
+ borderRadius: 'md',
+ gap: '1',
+ // width: 'full',
+ gridTemplateColumns: 'auto 1fr',
+ alignItems: 'center',
+ position: 'relative',
+ cursor: 'pointer',
+ borderWidth: '2px',
+ borderColor: 'colorPalette.a7',
+ color: 'colorPalette.text',
+ colorPalette: 'gray',
+ _hover: {
+ background: 'colorPalette.a2',
+ },
+ _disabled: {
+ borderColor: 'border.disabled',
+ color: 'fg.disabled',
+ cursor: 'not-allowed',
+ _hover: {
+ background: 'transparent',
+ borderColor: 'border.disabled',
+ color: 'fg.disabled',
+ },
+ },
+ _focusVisible: {
+ outline: '2px solid',
+ outlineColor: 'colorPalette.default',
+ outlineOffset: '2px',
+ },
+ _on: {
+ borderColor: 'accent.default',
+ color: 'accent.fg',
+ _hover: {
+ borderColor: 'accent.emphasized',
+ },
+ '&> .check-icon': {
+ display: 'block',
+ },
+ },
+ },
+});
+
+const CheckIconContainer = styled('div', {
+ base: {
+ position: 'absolute',
+ right: '-1',
+ top: '-1',
+ backgroundColor: 'accent.default',
+ display: 'none',
+ borderRadius: '50%',
+ padding: '0.5',
+ },
+});
diff --git a/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx b/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
new file mode 100644
index 0000000..c15e75b
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
@@ -0,0 +1,63 @@
+import { Index } from 'solid-js';
+
+import { usePureStore } from '@/store';
+import { $onlyShowDyeable, $selectedEquipSubCategory } from '@/store/toolTab';
+
+import { Grid } from 'styled-system/jsx/grid';
+import { Stack } from 'styled-system/jsx/stack';
+import * as ToggleGroup from '@/components/ui/toggleGroup';
+
+import { NeedDyeItem } from './NeedDyeItem';
+import type { EquipSubCategory } from '@/const/equipments';
+
+const CategoryList = [
+ 'Head',
+ 'Weapon',
+ 'Cap',
+ 'Cape',
+ 'Coat',
+ 'Glove',
+ 'Overall',
+ 'Pants',
+ 'Shield',
+ 'Shoes',
+ 'Face Accessory',
+ 'Eye Decoration',
+ 'Earrings',
+] as EquipSubCategory[];
+
+export const NeedDyeItemToggleGroup = () => {
+ const onlyShowDyeable = usePureStore($onlyShowDyeable);
+
+ const handleValueChange = (details: ToggleGroup.ValueChangeDetails) => {
+ $selectedEquipSubCategory.set(details.value as EquipSubCategory[]);
+ };
+
+ return (
+
+
+
+ {(category) => (
+ (
+
+ )}
+ />
+ )}
+
+
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/OnlyDyeableSwitch.tsx b/src/components/tab/ItemDyeTab/OnlyDyeableSwitch.tsx
new file mode 100644
index 0000000..bed1489
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/OnlyDyeableSwitch.tsx
@@ -0,0 +1,20 @@
+import { useStore } from '@nanostores/solid';
+
+import { $onlyShowDyeable } from '@/store/toolTab';
+
+import { Text } from '@/components/ui/text';
+import { Switch, type ChangeDetails } from '@/components/ui/switch';
+
+export const OnlyDyeableSwitch = () => {
+ const checked = useStore($onlyShowDyeable);
+
+ function handleChange(details: ChangeDetails) {
+ $onlyShowDyeable.set(details.checked);
+ }
+
+ return (
+
+ 僅顯示可染色裝備
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/PreserveDyeSwitch.tsx b/src/components/tab/ItemDyeTab/PreserveDyeSwitch.tsx
new file mode 100644
index 0000000..5a8df10
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/PreserveDyeSwitch.tsx
@@ -0,0 +1,28 @@
+import { useStore } from '@nanostores/solid';
+
+import { $preserveOriginalDye } from '@/store/toolTab';
+
+import { HStack } from 'styled-system/jsx/hstack';
+import { Text } from '@/components/ui/text';
+import { Switch, type ChangeDetails } from '@/components/ui/switch';
+import { IconTooltop, IconType } from '@/components/elements/IconTooltip';
+
+export const PreserveDyeSwitch = () => {
+ const checked = useStore($preserveOriginalDye);
+
+ function handleChange(details: ChangeDetails) {
+ $preserveOriginalDye.set(details.checked);
+ }
+
+ return (
+
+
+ 保留裝備染色
+
+
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/ResultActionSelect.tsx b/src/components/tab/ItemDyeTab/ResultActionSelect.tsx
new file mode 100644
index 0000000..b27f325
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ResultActionSelect.tsx
@@ -0,0 +1,18 @@
+import { useStore } from '@nanostores/solid';
+
+import { $dyeAction } from '@/store/toolTab';
+
+import { ActionSelect as BaseActionSelect } from '@/components/elements/ActionSelect';
+
+import type { CharacterAction } from '@/const/actions';
+
+export const ResultActionSelect = () => {
+ const action = useStore($dyeAction);
+ function handleActionChange(action: CharacterAction | undefined) {
+ action && $dyeAction.set(action);
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/ResultColumnCountNumberInput.tsx b/src/components/tab/ItemDyeTab/ResultColumnCountNumberInput.tsx
new file mode 100644
index 0000000..b8173c8
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ResultColumnCountNumberInput.tsx
@@ -0,0 +1,27 @@
+import { useStore } from '@nanostores/solid';
+
+import { $dyeResultColumnCount } from '@/store/toolTab';
+
+import {
+ NumberInput,
+ type ValueChangeDetails,
+} from '@/components/ui/numberInput';
+
+export const ResultColumnCountNumberInput = () => {
+ const count = useStore($dyeResultColumnCount);
+
+ function handleCountChange(details: ValueChangeDetails) {
+ $dyeResultColumnCount.set(details.valueAsNumber);
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/ResultCountNumberInput.tsx b/src/components/tab/ItemDyeTab/ResultCountNumberInput.tsx
new file mode 100644
index 0000000..851b139
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ResultCountNumberInput.tsx
@@ -0,0 +1,27 @@
+import { useStore } from '@nanostores/solid';
+
+import { $dyeResultCount } from '@/store/toolTab';
+
+import {
+ NumberInput,
+ type ValueChangeDetails,
+} from '@/components/ui/numberInput';
+
+export const ResultCountNumberInput = () => {
+ const count = useStore($dyeResultCount);
+
+ function handleCountChange(details: ValueChangeDetails) {
+ $dyeResultCount.set(details.valueAsNumber);
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/tab/ItemDyeTab/StartDyeButton.tsx b/src/components/tab/ItemDyeTab/StartDyeButton.tsx
new file mode 100644
index 0000000..f893c75
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/StartDyeButton.tsx
@@ -0,0 +1,63 @@
+import { Show } from 'solid-js';
+import { useStore } from '@nanostores/solid';
+import { styled } from 'styled-system/jsx/factory';
+
+import {
+ $selectedEquipSubCategory,
+ $dyeResultCount,
+ $dyeRenderId,
+ $dyeTypeEnabled,
+ $isRenderingDye,
+} from '@/store/toolTab';
+
+import LoaderCircle from 'lucide-solid/icons/loader-circle';
+import { Button } from '@/components/ui/button';
+
+import { toaster } from '@/components/GlobalToast';
+
+export const StartDyeButton = () => {
+ const isLoading = useStore($isRenderingDye);
+
+ function handleClick() {
+ if ($isRenderingDye.get()) {
+ return;
+ }
+ if ($selectedEquipSubCategory.get().length === 0) {
+ toaster.error({
+ title: '請選擇想要預覽染色的裝備',
+ });
+ return;
+ }
+ if (!$dyeTypeEnabled.get()) {
+ toaster.error({
+ title: '請選擇想要預覽的染色類型',
+ });
+ }
+ if ($dyeResultCount.get() < 32) {
+ toaster.error({
+ title: '請輸入染色數量',
+ });
+ return;
+ }
+ $dyeRenderId.set(Date.now().toString());
+ $isRenderingDye.set(true);
+ }
+
+ return (
+
+ );
+};
+
+const Loading = styled('div', {
+ base: {
+ animation: 'rotate infinite 1s linear',
+ color: 'fg.muted',
+ },
+});
diff --git a/src/components/tab/ItemDyeTab/index.tsx b/src/components/tab/ItemDyeTab/index.tsx
new file mode 100644
index 0000000..1e84a9a
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/index.tsx
@@ -0,0 +1,59 @@
+import { styled } from 'styled-system/jsx/factory';
+
+import { Stack } from 'styled-system/jsx/stack';
+
+import { HStack } from 'styled-system/jsx/hstack';
+import { Text } from '@/components/ui/text';
+import { Heading } from '@/components/ui/heading';
+import { ItemDyeTabTitle } from './ItemDyeTabTitle';
+import { NeedDyeItemToggleGroup } from './NeedDyeItemToggleGroup';
+import { DyeTypeRadioGroup } from './DyeTypeRadioGroup';
+import { ResultCountNumberInput } from './ResultCountNumberInput';
+import { ResultActionSelect } from './ResultActionSelect';
+import { StartDyeButton } from './StartDyeButton';
+import { DyeResult } from './DyeResult';
+
+export const ItemDyeTab = () => {
+ return (
+
+
+
+
+ 欲染色裝備
+
+
+
+ 染色類型
+
+
+
+ 其他設定
+
+ 染色動作
+
+
+
+ 染色結果數量
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const CardContainer = styled(Stack, {
+ base: {
+ p: 4,
+ borderRadius: 'md',
+ boxShadow: 'md',
+ backgroundColor: 'bg.default',
+ width: '100%',
+ },
+});
diff --git a/src/components/ui/radioGroup.tsx b/src/components/ui/radioGroup.tsx
new file mode 100644
index 0000000..9bf6eeb
--- /dev/null
+++ b/src/components/ui/radioGroup.tsx
@@ -0,0 +1,49 @@
+import { type Assign, RadioGroup } from '@ark-ui/solid';
+import type { ComponentProps } from 'solid-js';
+import { type RadioGroupVariantProps, radioGroup } from 'styled-system/recipes';
+import type { HTMLStyledProps } from 'styled-system/types';
+import { createStyleContext } from '@/utils/create-style-context';
+
+const { withProvider, withContext } = createStyleContext(radioGroup);
+
+export type RootProviderProps = ComponentProps;
+export const RootProvider = withProvider<
+ Assign<
+ Assign, RadioGroup.RootProviderBaseProps>,
+ RadioGroupVariantProps
+ >
+>(RadioGroup.RootProvider, 'root');
+
+export type RootProps = ComponentProps;
+export const Root = withProvider<
+ Assign<
+ Assign, RadioGroup.RootBaseProps>,
+ RadioGroupVariantProps
+ >
+>(RadioGroup.Root, 'root');
+
+export const Indicator = withContext<
+ Assign, RadioGroup.IndicatorBaseProps>
+>(RadioGroup.Indicator, 'indicator');
+
+export const ItemControl = withContext<
+ Assign, RadioGroup.ItemControlBaseProps>
+>(RadioGroup.ItemControl, 'itemControl');
+
+export const Item = withContext<
+ Assign, RadioGroup.ItemBaseProps>
+>(RadioGroup.Item, 'item');
+
+export const ItemText = withContext<
+ Assign, RadioGroup.ItemTextBaseProps>
+>(RadioGroup.ItemText, 'itemText');
+
+export const Label = withContext<
+ Assign, RadioGroup.LabelBaseProps>
+>(RadioGroup.Label, 'label');
+
+export {
+ RadioGroupContext as Context,
+ RadioGroupItemHiddenInput as ItemHiddenInput,
+ type RadioGroupValueChangeDetails as ValueChangeDetails,
+} from '@ark-ui/solid';
diff --git a/src/components/ui/toggleGroup.tsx b/src/components/ui/toggleGroup.tsx
index b18e0a8..c51b24d 100644
--- a/src/components/ui/toggleGroup.tsx
+++ b/src/components/ui/toggleGroup.tsx
@@ -16,10 +16,8 @@ export interface RootProps
ToggleGroupVariantProps {}
export const Root = withProvider(ToggleGroup.Root, 'root');
-export const Item = withContext>(
- ToggleGroup.Item,
- 'item',
-);
+export type ItemProps = Assign;
+export const Item = withContext(ToggleGroup.Item, 'item');
export {
ToggleGroupContext as Context,
diff --git a/src/const/toolTab.ts b/src/const/toolTab.ts
index 68afaf4..e54e8f9 100644
--- a/src/const/toolTab.ts
+++ b/src/const/toolTab.ts
@@ -2,12 +2,14 @@ export enum ToolTab {
AllAction = 'allAction',
HairDye = 'hairDye',
FaceDye = 'faceDye',
+ ItemDye = 'itemDye',
}
export const ToolTabNames: Record = {
[ToolTab.AllAction]: '全部動作',
[ToolTab.HairDye]: '髮型顏色',
[ToolTab.FaceDye]: '臉型顏色',
+ [ToolTab.ItemDye]: '裝備染色表',
};
export enum ActionExportType {
@@ -27,3 +29,13 @@ export const ActionExportTypeMimeType: Record = {
[ActionExportType.Apng]: 'image/png',
[ActionExportType.Webp]: 'image/webp',
};
+
+export enum DyeOrder {
+ Up = 'up',
+ Down = 'down',
+}
+export enum DyeType {
+ Hue = 'hue',
+ Saturation = 'saturation',
+ Birghtness = 'brightness',
+}
diff --git a/src/lucide-solid.d.ts b/src/lucide-solid.d.ts
index b31c157..8fd8e8b 100644
--- a/src/lucide-solid.d.ts
+++ b/src/lucide-solid.d.ts
@@ -10,3 +10,5 @@ declare module 'dom-to-image-more' {
import domToImage = require('dom-to-image');
export = domToImage;
}
+
+///
diff --git a/src/renderer/character/categorizedItem.ts b/src/renderer/character/categorizedItem.ts
index e4b654d..9dd97ae 100644
--- a/src/renderer/character/categorizedItem.ts
+++ b/src/renderer/character/categorizedItem.ts
@@ -192,7 +192,8 @@ export abstract class CategorizedItem {
continue;
}
- const isEar = pieceName.match(/ear/i);
+ /* some thing like capeArm also has ear... so only do end with here */
+ const isEar = pieceName.match(/ear$/i);
const name = isEar
? pieceName
diff --git a/src/renderer/character/character.ts b/src/renderer/character/character.ts
index f75c988..272af40 100644
--- a/src/renderer/character/character.ts
+++ b/src/renderer/character/character.ts
@@ -5,10 +5,7 @@ import type { CharacterData } from '@/store/character/store';
import type { ItemInfo, AncherName, Vec2, PieceSlot } from './const/data';
import type { CategorizedItem, CharacterActionItem } from './categorizedItem';
import type { CharacterAnimatablePart } from './characterAnimatablePart';
-import type {
- CharacterItemPiece,
- DyeableCharacterItemPiece,
-} from './itemPiece';
+import type { CharacterItemPiece, DyeableCharacterItemPiece } from './itemPiece';
import { CharacterLoader } from './loader';
import { CharacterItem } from './item';
@@ -44,8 +41,7 @@ class ZmapContainer extends Container {
} else if (this.name === 'pants' || this.name === 'backPants') {
this.requireLocks = ['Pn'];
} else {
- this.requireLocks =
- (CharacterLoader.smap?.[name] || '').match(/.{1,2}/g) || [];
+ this.requireLocks = (CharacterLoader.smap?.[name] || '').match(/.{1,2}/g) || [];
}
}
addCharacterPart(child: CharacterAnimatablePart) {
@@ -70,13 +66,14 @@ class ZmapContainer extends Container {
// ? frame.item.vslot
// : this.requireLocks;
let locks = this.requireLocks;
+ let itemMainLocks = part.item.islot; // something like Ma, Pn,Cp
// force Cap using vslot
- if (part.item.islot.includes('Cp')) {
+ if (itemMainLocks.includes('Cp')) {
locks = part.item.vslot;
} else if (
- part.item.islot.length === 1 &&
- part.item.islot[0] === 'Hd' &&
+ itemMainLocks.length === 1 &&
+ itemMainLocks[0] === 'Hd' &&
(this.name === 'accessoryOverHair' || this.name === 'hairShade')
) {
/* try to fix ear rendering */
@@ -86,6 +83,8 @@ class ZmapContainer extends Container {
// this logic is from maplestory.js, but why
if (this.name === 'mailChest') {
locks = part.item.vslot;
+ } else if (this.name === 'shoes') {
+ itemMainLocks = ['So'];
}
const hasSelfLock = this.hasAllLocks(part.item.info.id, part.item.vslot);
@@ -185,9 +184,7 @@ export class Character extends Container {
}
this.isAnimating = characterData.isAnimating;
const hasAttributeChanged = this.updateAttribute(characterData);
- const hasAddAnyItem = await this.updateItems(
- Object.values(characterData.items),
- );
+ const hasAddAnyItem = await this.updateItems(Object.values(characterData.items));
if (hasAttributeChanged || hasAddAnyItem || isStopToPlay) {
await this.loadItems();
} else if (isPlayingChanged) {
@@ -255,17 +252,13 @@ export class Character extends Container {
return;
}
item.info = Object.assign({}, info, {
- dye: (info as unknown as ItemInfo & { isDeleteDye: boolean }).isDeleteDye
- ? undefined
- : info.dye,
+ dye: (info as unknown as ItemInfo & { isDeleteDye: boolean }).isDeleteDye ? undefined : info.dye,
});
/* only update sprite already in render */
const dyeableSprites = this.currentAllItem
.flatMap((item) => Array.from(item.allPieces))
.filter((piece) => piece.item.info.id === id)
- .flatMap((piece) =>
- piece.frames.filter((frame) => frame.isDyeable?.()),
- ) as DyeableCharacterItemPiece[];
+ .flatMap((piece) => piece.frames.filter((frame) => frame.isDyeable?.())) as DyeableCharacterItemPiece[];
for await (const sprites of dyeableSprites) {
await sprites.updateDye();
}
@@ -275,18 +268,14 @@ export class Character extends Container {
get currentAllItem() {
return Array.from(this.idItems.values())
.map((item) => {
- return item.isUseExpressionItem
- ? item.actionPieces.get(this.expression)
- : item.actionPieces.get(this.action);
+ return item.isUseExpressionItem ? item.actionPieces.get(this.expression) : item.actionPieces.get(this.action);
})
.filter((item) => item) as AnyCategorizedItem[];
}
/** get current pieces in character layers */
get currentPieces() {
- return Array.from(this.zmapLayers.values()).flatMap(
- (layer) => layer.children as CharacterAnimatablePart[],
- );
+ return Array.from(this.zmapLayers.values()).flatMap((layer) => layer.children as CharacterAnimatablePart[]);
}
render() {
@@ -300,9 +289,7 @@ export class Character extends Container {
const earPiece = this.getEarPiece();
const earLayer = earPiece?.firstFrameZmapLayer;
for (const layer of zmap) {
- const itemsByLayer = this.getItemsByLayer(layer).concat(
- earPiece && earLayer === layer ? [earPiece] : [],
- );
+ const itemsByLayer = this.getItemsByLayer(layer).concat(earPiece && earLayer === layer ? [earPiece] : []);
if (itemsByLayer.length === 0) {
continue;
}
@@ -316,10 +303,7 @@ export class Character extends Container {
if (existLayer) {
container = existLayer;
} else {
- const zIndex =
- piece.effectZindex >= 2
- ? zmap.length + piece.effectZindex
- : piece.effectZindex - 10;
+ const zIndex = piece.effectZindex >= 2 ? zmap.length + piece.effectZindex : piece.effectZindex - 10;
container = new ZmapContainer(effectLayerName, zIndex, this);
this.addChild(container);
this.zmapLayers.set(effectLayerName, container);
@@ -329,10 +313,7 @@ export class Character extends Container {
this.addChild(container);
this.zmapLayers.set(layer, container);
}
- if (
- isBackAction(this.action) &&
- layer.toLocaleLowerCase().includes('face')
- ) {
+ if (isBackAction(this.action) && layer.toLocaleLowerCase().includes('face')) {
container.visible = false;
}
if ((layer === 'body' || layer === 'backBody') && piece.item.isBody) {
@@ -398,8 +379,7 @@ export class Character extends Container {
playByBody(body: CharacterAnimatablePart) {
const pieces = this.currentPieces;
const maxFrame = body.frames.length;
- const needBounce =
- this.action === CharacterAction.Alert || this.action.startsWith('stand');
+ const needBounce = this.action === CharacterAction.Alert || this.action.startsWith('stand');
this.currentTicker = (delta) => {
this.currentDelta += delta.deltaMS;
@@ -437,8 +417,7 @@ export class Character extends Container {
}
for (const piece of pieces) {
const pieceFrameIndex = piece.frames[frame] ? frame : 0;
- const pieceFrame = (piece.frames[frame] ||
- piece.frames[0]) as CharacterItemPiece;
+ const pieceFrame = (piece.frames[frame] || piece.frames[0]) as CharacterItemPiece;
const isSkinGroup = pieceFrame.group === 'skin';
if (pieceFrame) {
const ancherName = pieceFrame.baseAncherName;
@@ -452,7 +431,10 @@ export class Character extends Container {
y: 48,
};
/* cap effect use brow ancher */
- const browAncher = currentAncher.get('brow') || { x: 0, y: 0 };
+ const browAncher = currentAncher.get('brow') || {
+ x: 0,
+ y: 0,
+ };
ancher = {
x: baseAncher.x + browAncher.x,
y: baseAncher.y + browAncher.y,
@@ -515,15 +497,15 @@ export class Character extends Container {
get currentFrontBodyNode() {
const body = this.zmapLayers.get('body');
- return body?.children.find(
- (child) => (child as CharacterAnimatablePart).item.isBody,
- ) as CharacterAnimatablePart | undefined;
+ return body?.children.find((child) => (child as CharacterAnimatablePart).item.isBody) as
+ | CharacterAnimatablePart
+ | undefined;
}
get currentBackBodyNode() {
const body = this.zmapLayers.get('backBody');
- return body?.children.find(
- (child) => (child as CharacterAnimatablePart).item.isBody,
- ) as CharacterAnimatablePart | undefined;
+ return body?.children.find((child) => (child as CharacterAnimatablePart).item.isBody) as
+ | CharacterAnimatablePart
+ | undefined;
}
get currentBodyNode() {
@@ -545,14 +527,10 @@ export class Character extends Container {
}
get isAllAncherBuilt() {
- return Array.from(this.idItems.values()).every(
- (item) => item.isAllAncherBuilt,
- );
+ return Array.from(this.idItems.values()).every((item) => item.isAllAncherBuilt);
}
get isCurrentActionAncherBuilt() {
- return Array.from(this.idItems.values()).every((item) =>
- item.isActionAncherBuilt(this.action),
- );
+ return Array.from(this.idItems.values()).every((item) => item.isActionAncherBuilt(this.action));
}
get effectLayers() {
@@ -568,15 +546,11 @@ export class Character extends Container {
}
getEarPiece() {
- const headItem = Array.from(this.idItems.values()).find(
- (item) => item.isHead,
- );
+ const headItem = Array.from(this.idItems.values()).find((item) => item.isHead);
if (!headItem) {
return;
}
- const headCategoryItem = headItem.actionPieces.get(
- this.action,
- ) as CharacterActionItem;
+ const headCategoryItem = headItem.actionPieces.get(this.action) as CharacterActionItem;
const earItems = headCategoryItem?.getAvailableEar(this.earType);
@@ -671,10 +645,7 @@ export class Character extends Container {
for (const action of Object.values(CharacterAction)) {
for (const item of this.idItems.values()) {
const ancher = this.actionAnchers.get(action);
- this.actionAnchers.set(
- action,
- item.tryBuildAncher(action, ancher || []),
- );
+ this.actionAnchers.set(action, item.tryBuildAncher(action, ancher || []));
}
}
}
@@ -723,14 +694,12 @@ export class Character extends Container {
}
private updateHandTypeByAction() {
if (
- (this.action === CharacterAction.Walk1 ||
- this.action === CharacterAction.Stand1) &&
+ (this.action === CharacterAction.Walk1 || this.action === CharacterAction.Stand1) &&
this.#_handType === CharacterHandType.DoubleHand
) {
this.#_handType = CharacterHandType.SingleHand;
} else if (
- (this.action === CharacterAction.Walk2 ||
- this.action === CharacterAction.Stand2) &&
+ (this.action === CharacterAction.Walk2 || this.action === CharacterAction.Stand2) &&
this.#_handType === CharacterHandType.SingleHand
) {
this.#_handType = CharacterHandType.DoubleHand;
diff --git a/src/renderer/character/item.ts b/src/renderer/character/item.ts
index 8da1c9c..190198f 100644
--- a/src/renderer/character/item.ts
+++ b/src/renderer/character/item.ts
@@ -22,6 +22,7 @@ import {
isHeadId,
isBodyId,
isCapId,
+ isShoesId,
} from '@/utils/itemId';
import {
gatFaceAvailableColorIds,
@@ -206,8 +207,14 @@ export class CharacterItem implements RenderItemInfo {
return;
}
- this.islot = (this.wz.info.islot.match(/.{1,2}/g) || []) as PieceIslot[];
- this.vslot = (this.wz.info.vslot.match(/.{1,2}/g) || []) as PieceIslot[];
+ /* some item will not have info, WTF? */
+ this.islot = (this.wz.info?.islot?.match(/.{1,2}/g) || []) as PieceIslot[];
+ this.vslot = (this.wz.info?.vslot?.match(/.{1,2}/g) || []) as PieceIslot[];
+
+ /* a shoe should alwasy be a shoe! pls */
+ if (isShoesId(this.info.id) && !this.islot.includes('So')) {
+ this.islot = ['So'];
+ }
/* resolve dye */
if (this.isFace && this.avaliableDye.size === 0) {
diff --git a/src/renderer/filter/anime4k/Anime4kFilter.ts b/src/renderer/filter/anime4k/Anime4kFilter.ts
new file mode 100644
index 0000000..2680c06
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kFilter.ts
@@ -0,0 +1,270 @@
+import {
+ Filter,
+ Texture,
+ GpuProgram,
+ Geometry,
+ Point,
+ type WebGPURenderer,
+ type FilterSystem,
+ type RenderSurface,
+} from 'pixi.js';
+import { wgslVertex } from 'pixi-filters';
+import './Anime4kSystem';
+
+import sampleExternalTextureFrag from './sampleExternalTexture.wgsl';
+
+import type { PipelineOption } from './const';
+
+const quadGeometry = new Geometry({
+ attributes: {
+ aPosition: {
+ buffer: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
+ format: 'float32x2',
+ stride: 2 * 4,
+ offset: 0,
+ },
+ },
+ indexBuffer: new Uint32Array([0, 1, 2, 0, 2, 3]),
+});
+
+export class Anime4kFilter extends Filter {
+ loadedPipeline: PipelineOption[] = [];
+
+ constructor(pipelines: PipelineOption[]) {
+ const gpuProgram = GpuProgram.from({
+ vertex: {
+ source: wgslVertex,
+ entryPoint: 'mainVertex',
+ },
+ fragment: {
+ source: sampleExternalTextureFrag,
+ entryPoint: 'main',
+ },
+ });
+ super({
+ gpuProgram,
+ resources: {},
+ });
+ this.loadedPipeline = pipelines;
+ }
+ public override apply(
+ filterManager: FilterSystem,
+ input: Texture,
+ output: RenderSurface,
+ clearMode: boolean,
+ ) {
+ const renderer = filterManager.renderer as WebGPURenderer;
+ const globalBindGroup = this.getPixiGlobalBindGroup(
+ filterManager,
+ input,
+ output,
+ );
+ this.groups[0] = globalBindGroup;
+
+ /* FilterSystem done */
+
+ const res = (filterManager.renderer as WebGPURenderer).texture.getGpuSource(
+ /* @ts-ignore */
+ input.source,
+ );
+
+ const sizeOption = {
+ width: input.source.width,
+ height: input.source.height,
+ };
+ const resource = renderer.anime4k.getSizedRenderResource(
+ sizeOption,
+ this.loadedPipeline,
+ );
+
+ /* copy incoming texture to mainTexture so anime4k can process it */
+ renderer.encoder.commandEncoder.copyTextureToTexture(
+ {
+ texture: res,
+ },
+ {
+ texture: resource[0],
+ },
+ [input.source.width, input.source.height, 1],
+ );
+
+ for (const pipe of resource[2]) {
+ pipe.pass(renderer.encoder.commandEncoder);
+ }
+
+ renderer.renderTarget.bind(output, !!clearMode);
+
+ renderer.encoder.setPipelineFromGeometryProgramAndState(
+ quadGeometry,
+ this.gpuProgram,
+ this._state,
+ 'triangle-list',
+ );
+ renderer.encoder.setGeometry(quadGeometry, this.gpuProgram);
+
+ /* @ts-ignore */
+ renderer.encoder._syncBindGroup(globalBindGroup);
+ /* @ts-ignore */
+ renderer.encoder._boundBindGroup[0] = globalBindGroup;
+
+ globalBindGroup._touch(renderer.textureGC.count);
+
+ /* create a bindGroup and use last pipeline result as texture */
+ const bindGroup = renderer.anime4k.getSizedBindGroup(sizeOption, {
+ bindGroup: globalBindGroup,
+ program: this.gpuProgram,
+ groupIndex: 0,
+ resourceView: resource[1],
+ });
+
+ renderer.encoder.setPipelineFromGeometryProgramAndState(
+ quadGeometry,
+ this.gpuProgram,
+ this._state,
+ 'triangle-list',
+ );
+ renderer.encoder.setGeometry(quadGeometry, this.gpuProgram);
+ renderer.encoder.renderPassEncoder.setBindGroup(0, bindGroup);
+ renderer.encoder.renderPassEncoder.drawIndexed(
+ quadGeometry.indexBuffer.data.length,
+ quadGeometry.instanceCount,
+ 0,
+ );
+ }
+ public updatePipeine(pipelines: PipelineOption[]) {
+ this.loadedPipeline = pipelines;
+ }
+ private getPixiGlobalBindGroup(
+ filterManager: FilterSystem,
+ input: Texture,
+ output: RenderSurface,
+ ) {
+ const renderer = filterManager.renderer as WebGPURenderer;
+ const renderTarget = renderer.renderTarget.getRenderTarget(output);
+ const rootTexture = renderer.renderTarget.rootRenderTarget.colorTexture;
+ /* FilterSystem stuff start, the code is just copy paste from pixi.js v8 src/filters/FilterSystem */
+ /* @ts-ignore */
+ const filterData =
+ /* @ts-ignore */
+ filterManager._filterStack[filterManager._filterStackIndex];
+ const bounds = filterData.bounds;
+
+ const offset = Point.shared;
+
+ const previousRenderSurface = filterData.previousRenderSurface;
+
+ const isFinalTarget = previousRenderSurface === output;
+
+ let resolution = rootTexture.source._resolution;
+
+ // to find the previous resolution we need to account for the skipped filters
+ // the following will find the last non skipped filter...
+ /* @ts-ignore */
+ let currentIndex = this._filterStackIndex - 1;
+
+ /* @ts-ignore */
+ while (currentIndex > 0 && this._filterStack[currentIndex].skip) {
+ --currentIndex;
+ }
+
+ if (currentIndex > 0) {
+ resolution =
+ /* @ts-ignore */
+ this._filterStack[currentIndex].inputTexture.source._resolution;
+ }
+
+ /* it private it also necessary access that here */
+ /* @ts-ignore */
+ const filterUniforms = filterManager._filterGlobalUniforms;
+ const uniforms = filterUniforms.uniforms;
+
+ const outputFrame = uniforms.uOutputFrame;
+ const inputSize = uniforms.uInputSize;
+ const inputPixel = uniforms.uInputPixel;
+ const inputClamp = uniforms.uInputClamp;
+ const globalFrame = uniforms.uGlobalFrame;
+ const outputTexture = uniforms.uOutputTexture;
+
+ // are we rendering back to the original surface?
+ if (isFinalTarget) {
+ /* @ts-ignore */
+ let lastIndex = filterManager._filterStackIndex;
+
+ // get previous bounds.. we must take into account skipped filters also..
+ while (lastIndex > 0) {
+ lastIndex--;
+ const filterData =
+ /* @ts-ignore */
+ filterManager._filterStack[filterManager._filterStackIndex - 1];
+
+ if (!filterData.skip) {
+ offset.x = filterData.bounds.minX;
+ offset.y = filterData.bounds.minY;
+
+ break;
+ }
+ }
+
+ outputFrame[0] = bounds.minX - offset.x;
+ outputFrame[1] = bounds.minY - offset.y;
+ } else {
+ outputFrame[0] = 0;
+ outputFrame[1] = 0;
+ }
+
+ outputFrame[2] = input.frame.width;
+ outputFrame[3] = input.frame.height;
+
+ inputSize[0] = input.source.width;
+ inputSize[1] = input.source.height;
+ inputSize[2] = 1 / inputSize[0];
+ inputSize[3] = 1 / inputSize[1];
+
+ inputPixel[0] = input.source.pixelWidth;
+ inputPixel[1] = input.source.pixelHeight;
+ inputPixel[2] = 1.0 / inputPixel[0];
+ inputPixel[3] = 1.0 / inputPixel[1];
+
+ inputClamp[0] = 0.5 * inputPixel[2];
+ inputClamp[1] = 0.5 * inputPixel[3];
+ inputClamp[2] = input.frame.width * inputSize[2] - 0.5 * inputPixel[2];
+ inputClamp[3] = input.frame.height * inputSize[3] - 0.5 * inputPixel[3];
+
+ globalFrame[0] = offset.x * resolution;
+ globalFrame[1] = offset.y * resolution;
+
+ globalFrame[2] = rootTexture.source.width * resolution;
+ globalFrame[3] = rootTexture.source.height * resolution;
+
+ if (output instanceof Texture) {
+ outputTexture[0] = output.frame.width;
+ outputTexture[1] = output.frame.height;
+ } else {
+ // this means a renderTarget was passed directly
+ outputTexture[0] = renderTarget.width;
+ outputTexture[1] = renderTarget.height;
+ }
+
+ outputTexture[2] = renderTarget.isRoot ? -1 : 1;
+ filterUniforms.update();
+
+ /* @ts-ignore */
+ const globalBindGroup = filterManager._globalFilterBindGroup;
+ if (renderer.renderPipes.uniformBatch) {
+ const batchUniforms =
+ renderer.renderPipes.uniformBatch.getUboResource(filterUniforms);
+
+ globalBindGroup.setResource(batchUniforms, 0);
+ } else {
+ globalBindGroup.setResource(filterUniforms, 0);
+ }
+
+ // now lets update the output texture...
+
+ // set bind group..
+ globalBindGroup.setResource(input.source, 1);
+ globalBindGroup.setResource(input.source.style, 2);
+
+ return globalBindGroup;
+ }
+}
diff --git a/src/renderer/filter/anime4k/Anime4kSyetem.d.ts b/src/renderer/filter/anime4k/Anime4kSyetem.d.ts
new file mode 100644
index 0000000..deb630d
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kSyetem.d.ts
@@ -0,0 +1,9 @@
+declare global {
+ namespace PixiMixins {
+ interface RendererSystems {
+ anime4k: import('./Anime4kSystem').Anime4kFilterSystem;
+ }
+ }
+}
+
+export {};
diff --git a/src/renderer/filter/anime4k/Anime4kSystem.ts b/src/renderer/filter/anime4k/Anime4kSystem.ts
new file mode 100644
index 0000000..3b1e8a7
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kSystem.ts
@@ -0,0 +1,273 @@
+import {
+ extensions,
+ ExtensionType,
+ type WebGPURenderer,
+ type Renderer,
+ type System,
+ type GpuProgram,
+ type BindGroup,
+ type UniformGroup,
+ type TextureStyle,
+ type BufferResource,
+ type Buffer,
+ type BindResource,
+ type GPU,
+} from 'pixi.js';
+import type { Anime4KPipeline } from 'anime4k-webgpu';
+import {
+ PipelineMap,
+ type PipelineType,
+ type PipelineOption,
+ type Anime4kPipelineConstructor,
+} from './const';
+
+export type SizedRenderResourceTuple = [
+ GPUTexture,
+ GPUTextureView,
+ Anime4KPipeline[],
+];
+
+export class Anime4kFilterSystem implements System {
+ public static extension = {
+ type: [ExtensionType.WebGPUSystem],
+ name: 'anime4k',
+ } as const;
+
+ private _renderer: Renderer;
+ private _pipelineMap: Map =
+ new Map();
+ private _sizedPipelineMap: Map> =
+ new Map();
+ private _sizedRenderMap: Map =
+ new Map();
+ private _sizedBindGroupMap: Map = new Map();
+ private _gpu!: GPU;
+
+ constructor(renderer: Renderer) {
+ this._renderer = renderer;
+ }
+
+ protected contextChange(gpu: GPU): void {
+ this._gpu = gpu;
+ }
+
+ public destroy(): void {
+ /* @ts-ignore */
+ this._renderer = null;
+ }
+ public clear(): void {
+ this._sizedRenderMap.clear();
+ this._sizedBindGroupMap.clear();
+ this._sizedPipelineMap.clear();
+ }
+
+ public preparePipeline(pipelines: PipelineType[]) {
+ const unresolved = pipelines.filter((p) => !this._pipelineMap.has(p));
+ return Promise.all(
+ unresolved.map((p) =>
+ PipelineMap[p]().then((m) =>
+ this._pipelineMap.set(p, m as Anime4kPipelineConstructor),
+ ),
+ ),
+ );
+ }
+
+ private getSizedPipeline(
+ option: { width: number; height: number; inputTexture: GPUTexture },
+ type: PipelineType,
+ index: number,
+ ) {
+ const key = `${option.width}x${option.height}`;
+ let sizedMap = this._sizedPipelineMap.get(key);
+ if (!sizedMap) {
+ sizedMap = new Map();
+ this._sizedPipelineMap.set(key, sizedMap);
+ }
+
+ const pipelineClass = this._pipelineMap.get(type);
+ if (!pipelineClass) {
+ throw new Error(`Pipeline ${type} not prepared or not found`);
+ }
+
+ const pipelineKey = `${type}:${index}`;
+ let targetPipeline = sizedMap.get(pipelineKey);
+
+ if (!targetPipeline) {
+ targetPipeline = new pipelineClass({
+ device: this._gpu.device,
+ inputTexture: option.inputTexture,
+ nativeDimensions: {
+ width: option.width,
+ height: option.height,
+ },
+ targetDimensions: {
+ width: option.width,
+ height: option.height,
+ },
+ });
+ sizedMap.set(pipelineKey, targetPipeline);
+ }
+
+ return targetPipeline;
+ }
+ getSizedRenderResource(
+ option: {
+ width: number;
+ height: number;
+ },
+ pipelines: PipelineOption[],
+ ) {
+ const pipelineHash = pipelines.map((p) => p.pipeline).join(',');
+ const key = `${option.width}x${option.height}:${pipelineHash}`;
+ let resource = this._sizedRenderMap.get(key);
+ if (!resource) {
+ resource = this.setSizedRenderResource(option, pipelines);
+ this._sizedRenderMap.set(key, resource);
+ }
+
+ return resource;
+ }
+ setSizedRenderResource(
+ option: {
+ width: number;
+ height: number;
+ },
+ pipelines: PipelineOption[],
+ ): SizedRenderResourceTuple {
+ const device = this._gpu.device;
+ const mainTexture = device.createTexture({
+ size: [option.width, option.height, 1],
+ format: 'bgra8unorm',
+ usage:
+ GPUTextureUsage.TEXTURE_BINDING |
+ GPUTextureUsage.COPY_DST |
+ GPUTextureUsage.COPY_SRC |
+ GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+
+ const pipelineChain = pipelines.reduce(
+ (chain: Anime4KPipeline[], pipeline, index) => {
+ const inputTexture =
+ index === 0 ? mainTexture : chain[index - 1].getOutputTexture();
+ const pipe = this.getSizedPipeline(
+ {
+ width: option.width,
+ height: option.height,
+ inputTexture,
+ },
+ pipeline.pipeline,
+ index,
+ );
+ /* notice that just direct update, means all thing using this will also got update */
+ if (pipeline.params) {
+ for (const [key, value] of Object.entries(pipeline.params)) {
+ pipe.updateParam(key, value);
+ }
+ }
+ chain.push(pipe);
+ return chain;
+ },
+ [] as Anime4KPipeline[],
+ );
+ const lastPipeline = pipelineChain[pipelineChain.length - 1];
+ return [
+ mainTexture,
+ lastPipeline.getOutputTexture().createView(),
+ pipelineChain,
+ ];
+ }
+ getSizedBindGroup(
+ option: { width: number; height: number },
+ groupOption: {
+ bindGroup: BindGroup;
+ program: GpuProgram;
+ groupIndex: number;
+ resourceView: GPUTextureView;
+ },
+ ) {
+ const key = `${option.width}x${option.height}`;
+ let bindGroup = this._sizedBindGroupMap.get(key);
+ if (!bindGroup) {
+ bindGroup = this.setSizedBindGroup(groupOption);
+ this._sizedBindGroupMap.set(key, bindGroup);
+ }
+ return bindGroup;
+ }
+ setSizedBindGroup(groupOption: {
+ bindGroup: BindGroup;
+ program: GpuProgram;
+ groupIndex: number;
+ resourceView: GPUTextureView;
+ }) {
+ const device = this._gpu.device;
+ const renderer = this._renderer as WebGPURenderer;
+ const { bindGroup, program, groupIndex, resourceView } = groupOption;
+ const groupLayout = program.layout[groupIndex];
+ const entries: GPUBindGroupEntry[] = [];
+
+ for (const j in groupLayout) {
+ const resource: BindResource =
+ bindGroup.resources[j] ?? bindGroup.resources[groupLayout[j]];
+ let gpuResource!:
+ | GPUSampler
+ | GPUTextureView
+ | GPUExternalTexture
+ | GPUBufferBinding;
+ // TODO make this dynamic..
+
+ if (resource._resourceType === 'uniformGroup') {
+ const uniformGroup = resource as UniformGroup;
+
+ renderer.ubo.updateUniformGroup(uniformGroup as UniformGroup);
+
+ const buffer = uniformGroup.buffer as Buffer;
+
+ gpuResource = {
+ buffer: renderer.buffer.getGPUBuffer(buffer),
+ offset: 0,
+ size: buffer.descriptor.size,
+ };
+ } else if (resource._resourceType === 'buffer') {
+ const buffer = resource as Buffer;
+
+ gpuResource = {
+ buffer: renderer.buffer.getGPUBuffer(buffer),
+ offset: 0,
+ size: buffer.descriptor.size,
+ };
+ } else if (resource._resourceType === 'bufferResource') {
+ const bufferResource = resource as BufferResource;
+
+ gpuResource = {
+ buffer: renderer.buffer.getGPUBuffer(bufferResource.buffer),
+ offset: bufferResource.offset,
+ size: bufferResource.size,
+ };
+ } else if (resource._resourceType === 'textureSampler') {
+ const sampler = resource as TextureStyle;
+
+ gpuResource = renderer.texture.getGpuSampler(sampler);
+ } else if (resource._resourceType === 'textureSource') {
+ gpuResource = resourceView;
+ }
+
+ if (gpuResource) {
+ entries.push({
+ binding: groupLayout[j],
+ resource: gpuResource,
+ });
+ }
+ }
+
+ const layout =
+ renderer.shader.getProgramData(program).bindGroups[groupIndex];
+ const gpuBindGroup = device.createBindGroup({
+ layout,
+ entries,
+ });
+
+ return gpuBindGroup;
+ }
+}
+
+extensions.add(Anime4kFilterSystem);
diff --git a/src/renderer/filter/anime4k/const.ts b/src/renderer/filter/anime4k/const.ts
new file mode 100644
index 0000000..f450400
--- /dev/null
+++ b/src/renderer/filter/anime4k/const.ts
@@ -0,0 +1,110 @@
+import type { Anime4KPipeline } from 'anime4k-webgpu';
+
+export enum PipelineType {
+ /**
+ * deblur, must set param: strength
+ *
+ * @param strength Deblur Strength (0.1 - 15.0)
+ */
+ Dog = 'Dog',
+ /**
+ * denoise, must set param: strength, strength2
+ *
+ * @param strength Itensity Sigma (0.1 - 2.0)
+ * @param strength2 Spatial Sigma (1 - 15)
+ */
+ BilateralMean = 'BilateralMean',
+
+ /** restore */
+ CNNM = 'CNNM',
+ /** restore */
+ CNNSoftM = 'CNNSoftM',
+ /** restore */
+ CNNSoftVL = 'CNNSoftVL',
+ /** restore */
+ CNNVL = 'CNNVL',
+ /** restore */
+ CNNUL = 'CNNUL',
+ /** restore */
+ GANUUL = 'GANUUL',
+
+ /** upscale */
+ CNNx2M = 'CNNx2M',
+ /** upscale */
+ CNNx2VL = 'CNNx2VL',
+ /** upscale */
+ DenoiseCNNx2VL = 'DenoiseCNNx2VL',
+ /** upscale */
+ CNNx2UL = 'CNNx2UL',
+ /** upscale */
+ GANx3L = 'GANx3L',
+ /** upscale */
+ GANx4UUL = 'GANx4UUL',
+
+ ClampHighlights = 'ClampHighlights',
+ DepthToSpace = 'DepthToSpace',
+
+ /* anime4k preset @see https://github.com/bloc97/Anime4K/blob/master/md/GLSL_Instructions_Advanced.md */
+ /** Restore -> Upscale -> Upscale */
+ ModeA = 'ModeA',
+ /** Restore_Soft -> Upscale -> Upscale */
+ ModeB = 'ModeB',
+ /** Upscale_Denoise -> Upscale */
+ ModeC = 'ModeC',
+ /** Restore -> Upscale -> Restore -> Upscale */
+ ModeAA = 'ModeAA',
+ /** Restore_Soft -> Upscale -> Restore_Soft -> Upscale */
+ ModeBB = 'ModeBB',
+ /** Upscale_Denoise -> Restore -> Upscale */
+ ModeCA = 'ModeCA',
+}
+
+export const PipelineMap: Record Promise> = {
+ [PipelineType.Dog]: () => import('anime4k-webgpu').then((m) => m.DoG),
+ [PipelineType.BilateralMean]: () =>
+ import('anime4k-webgpu').then((m) => m.BilateralMean),
+
+ [PipelineType.CNNM]: () => import('anime4k-webgpu').then((m) => m.CNNM),
+ [PipelineType.CNNSoftM]: () =>
+ import('anime4k-webgpu').then((m) => m.CNNSoftM),
+ [PipelineType.CNNSoftVL]: () =>
+ import('anime4k-webgpu').then((m) => m.CNNSoftVL),
+ [PipelineType.CNNVL]: () => import('anime4k-webgpu').then((m) => m.CNNVL),
+ [PipelineType.CNNUL]: () => import('anime4k-webgpu').then((m) => m.CNNUL),
+ [PipelineType.GANUUL]: () => import('anime4k-webgpu').then((m) => m.GANUUL),
+
+ [PipelineType.CNNx2M]: () => import('anime4k-webgpu').then((m) => m.CNNx2M),
+ [PipelineType.CNNx2VL]: () => import('anime4k-webgpu').then((m) => m.CNNx2VL),
+ [PipelineType.DenoiseCNNx2VL]: () =>
+ import('anime4k-webgpu').then((m) => m.DenoiseCNNx2VL),
+ [PipelineType.CNNx2UL]: () => import('anime4k-webgpu').then((m) => m.CNNx2UL),
+ [PipelineType.GANx3L]: () => import('anime4k-webgpu').then((m) => m.GANx3L),
+ [PipelineType.GANx4UUL]: () =>
+ import('anime4k-webgpu').then((m) => m.GANx4UUL),
+
+ [PipelineType.ClampHighlights]: () =>
+ import('anime4k-webgpu').then((m) => m.ClampHighlights),
+ [PipelineType.DepthToSpace]: () =>
+ import('anime4k-webgpu').then((m) => m.DepthToSpace),
+
+ [PipelineType.ModeA]: () => import('anime4k-webgpu').then((m) => m.ModeA),
+ [PipelineType.ModeB]: () => import('anime4k-webgpu').then((m) => m.ModeB),
+ [PipelineType.ModeC]: () => import('anime4k-webgpu').then((m) => m.ModeC),
+ [PipelineType.ModeAA]: () => import('anime4k-webgpu').then((m) => m.ModeAA),
+ [PipelineType.ModeBB]: () => import('anime4k-webgpu').then((m) => m.ModeBB),
+ [PipelineType.ModeCA]: () => import('anime4k-webgpu').then((m) => m.ModeCA),
+};
+export interface PipelineOption {
+ pipeline: PipelineType;
+ params?: Record;
+}
+export type Anime4kPipelineConstructor = new (desc: {
+ device: GPUDevice;
+ inputTexture: GPUTexture;
+ nativeDimensions?: { width: number; height: number };
+ targetDimensions?: { width: number; height: number };
+}) => Anime4KPipeline;
+export interface Anime4kPipelineWithOption {
+ type: PipelineType;
+ params?: Record;
+}
diff --git a/src/renderer/filter/anime4k/index.ts b/src/renderer/filter/anime4k/index.ts
index 0419db2..1e5f5a8 100644
--- a/src/renderer/filter/anime4k/index.ts
+++ b/src/renderer/filter/anime4k/index.ts
@@ -114,7 +114,9 @@ export async function createGpuDevice(canvas: HTMLCanvasElement) {
if (!adapter) {
throw new Error('WebGPU is not supported');
}
- const device = await adapter.requestDevice();
+ const device = await adapter.requestDevice({
+ label: 'Test Anime4k GPU Device',
+ });
const context = canvas.getContext('webgpu') as GPUCanvasContext;
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
diff --git a/src/renderer/filter/anime4k/sampleExternalTexture.wgsl b/src/renderer/filter/anime4k/sampleExternalTexture.wgsl
index 2305e0b..d501425 100644
--- a/src/renderer/filter/anime4k/sampleExternalTexture.wgsl
+++ b/src/renderer/filter/anime4k/sampleExternalTexture.wgsl
@@ -1,7 +1,7 @@
-@group(0) @binding(1) var mySampler: sampler;
-@group(0) @binding(2) var myTexture: texture_2d;
+@group(0) @binding(1) var uTexture: texture_2d;
+@group(0) @binding(2) var uSampler: sampler;
@fragment
fn main(@location(0) fragUV : vec2f) -> @location(0) vec4f {
- return textureSampleBaseClampToEdge(myTexture, mySampler, fragUV);
+ return textureSampleBaseClampToEdge(uTexture, uSampler, fragUV);
}
\ No newline at end of file
diff --git a/src/store/equipDrawer.ts b/src/store/equipDrawer.ts
index 89a660d..8f0deb3 100644
--- a/src/store/equipDrawer.ts
+++ b/src/store/equipDrawer.ts
@@ -91,8 +91,9 @@ export const $categoryFilteredString = computed(
$equipmentDrawerEquipTab,
$currentEquipmentDrawerCategory,
$equipmentStrings,
+ $equipmentDrawerOnlyShowDyeable,
],
- (tab, category, strings) => {
+ (tab, category, strings, onlyShowDyeable) => {
if (tab === EquipTab.History) {
/* not subscribe $equipmentHistory here */
return $equipmentHistory.get();
@@ -116,41 +117,36 @@ export const $categoryFilteredString = computed(
});
}
- if (category === AllCategory) {
- return strings;
+ let filteredStrings = strings;
+
+ if (category !== AllCategory) {
+ const mainCategory = getCategoryBySubCategory(category);
+ filteredStrings = strings.filter((item) => {
+ if (item.category === mainCategory) {
+ return getSubCategory(item.id) === category;
+ }
+ return false;
+ });
}
- const mainCategory = getCategoryBySubCategory(category);
- return strings.filter((item) => {
- if (item.category === mainCategory) {
- return getSubCategory(item.id) === category;
- }
- return false;
- });
+ if (!onlyShowDyeable) {
+ return filteredStrings;
+ }
+
+ return strings.filter(({ isDyeable }) => isDyeable);
},
);
export const $equipmentDrawerEquipFilteredString = computed(
- [
- $categoryFilteredString,
- $currentEquipmentDrawerSearch,
- $equipmentDrawerOnlyShowDyeable,
- ],
- (strings, searchKey, onlyShowDyeable) => {
+ [$categoryFilteredString, $currentEquipmentDrawerSearch],
+ (strings, searchKey) => {
if (searchKey) {
return strings.filter((item) => {
const isMatch =
item.name.includes(searchKey) || item.id.toString() === searchKey;
- if (!onlyShowDyeable) {
- return isMatch;
- }
- return isMatch && item.isDyeable;
+ return isMatch;
});
}
- if (!onlyShowDyeable) {
- return strings;
- }
-
- return strings.filter(({ isDyeable }) => isDyeable);
+ return strings;
},
);
diff --git a/src/store/settingDialog.ts b/src/store/settingDialog.ts
index 758c2a4..38c7f7b 100644
--- a/src/store/settingDialog.ts
+++ b/src/store/settingDialog.ts
@@ -96,6 +96,10 @@ export async function initializeSavedSetting() {
$appSetting.setKey('windowResizable', !!setting.windowResizable);
$appSetting.setKey('showItemGender', setting.showItemGender ?? true);
$appSetting.setKey('showItemDyeable', setting.showItemDyeable ?? true);
+ $appSetting.setKey(
+ 'enableExperimentalUpscale',
+ !!setting.enableExperimentalUpscale,
+ );
const defaultCharacterRendering = !!setting.defaultCharacterRendering;
if (defaultCharacterRendering) {
$appSetting.setKey('defaultCharacterRendering', true);
diff --git a/src/store/toolTab.ts b/src/store/toolTab.ts
index be06784..7384475 100644
--- a/src/store/toolTab.ts
+++ b/src/store/toolTab.ts
@@ -1,7 +1,112 @@
-import { atom } from 'nanostores';
+import { atom, deepMap, batched, onSet } from 'nanostores';
-import { type ToolTab, ActionExportType } from '@/const/toolTab';
+import type { EquipSubCategory } from '@/const/equipments';
+import {
+ ToolTab,
+ ActionExportType,
+ DyeOrder,
+ DyeType,
+} from '@/const/toolTab';
+import { CharacterAction } from '@/const/actions';
export const $toolTab = atom(undefined);
-export const $actionExportType = atom(ActionExportType.Gif);
+export const $actionExportType = atom(
+ ActionExportType.Gif,
+);
+
+/* item dye tab */
+export const $onlyShowDyeable = atom(true);
+export const $preserveOriginalDye = atom(true);
+export const $selectedEquipSubCategory = atom([]);
+export const $dyeResultCount = atom(72);
+export const $dyeAction = atom(CharacterAction.Stand1);
+
+export const $dyeRenderId = atom(undefined);
+export const $isRenderingDye = atom(false);
+export const $dyeResultColumnCount = atom(8);
+
+export interface DyeConfigOption {
+ enabled: boolean;
+ order: DyeOrder;
+}
+export type DyeConfig = {
+ hue: DyeConfigOption;
+ saturation: DyeConfigOption;
+ brightness: DyeConfigOption;
+};
+export const $dyeConfig = deepMap({
+ hue: {
+ enabled: true,
+ order: DyeOrder.Up,
+ },
+ saturation: {
+ enabled: false,
+ order: DyeOrder.Up,
+ },
+ brightness: {
+ enabled: false,
+ order: DyeOrder.Up,
+ },
+});
+
+/* effect */
+onSet($toolTab, ({ newValue }) => {
+ /* clean render id prevent render table againg when return */
+ if (newValue !== ToolTab.ItemDye) {
+ $dyeRenderId.set(undefined);
+ }
+});
+
+/* selector */
+export const $dyeTypeEnabled = batched($dyeConfig, (config) => {
+ for (const k of Object.values(DyeType) as DyeType[]) {
+ if (config[k].enabled) {
+ return k;
+ }
+ }
+ return undefined;
+});
+export const $isExportable = batched(
+ [$isRenderingDye, $dyeRenderId],
+ (isRendering, renderId) => {
+ return !isRendering && !!renderId;
+ },
+);
+
+/* action */
+export function disableOtherDyeConfig(key: DyeType) {
+ for (const k of Object.values(DyeType) as DyeType[]) {
+ if (k !== key) {
+ $dyeConfig.setKey(`${k}.enabled`, false);
+ }
+ }
+}
+export function toggleDyeConfigEnabled(key: DyeType, value: boolean) {
+ $dyeConfig.setKey(`${key}.enabled`, value);
+ disableOtherDyeConfig(key);
+}
+export function toggleDyeConfigOrder(key: DyeType, order: DyeOrder) {
+ $dyeConfig.setKey(`${key}.order`, order);
+}
+export function selectDyeCategory(category: EquipSubCategory) {
+ const current = $selectedEquipSubCategory.get();
+ if (current.includes(category)) {
+ return;
+ }
+ $selectedEquipSubCategory.set([
+ ...$selectedEquipSubCategory.get(),
+ category,
+ ]);
+}
+export function deselectDyeCategory(category: EquipSubCategory) {
+ $selectedEquipSubCategory.set(
+ $selectedEquipSubCategory.get().filter((c) => c !== category),
+ );
+}
+export function setDyeResultCount(count: number) {
+ $dyeResultCount.set(count);
+}
+export function setDyeAction(action: CharacterAction) {
+ $dyeAction.set(action);
+}
diff --git a/src/utils/extract.ts b/src/utils/extract.ts
index c099cfc..5a4ccc8 100644
--- a/src/utils/extract.ts
+++ b/src/utils/extract.ts
@@ -9,6 +9,14 @@ import {
type ExtractTarget = Parameters[0];
+export function getBlobFromCanvas(canvas: HTMLCanvasElement) {
+ return new Promise((resolve) => {
+ canvas.toBlob?.((blob) => {
+ resolve(blob as unknown as Blob);
+ }, 'image/png');
+ });
+}
+
export function extractCanvas(target: ExtractTarget, renderer: Renderer) {
const texture = renderer.extract.texture(target);