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);