diff --git a/packages/webgal/public/game/scene/demo_zh_cn.txt b/packages/webgal/public/game/scene/demo_zh_cn.txt index 9776aa6f1..73f93057c 100644 --- a/packages/webgal/public/game/scene/demo_zh_cn.txt +++ b/packages/webgal/public/game/scene/demo_zh_cn.txt @@ -6,7 +6,8 @@ setTransition: -target=bg-main -exit=shockwaveOut; :你好|欢迎来到 {engine} 的世界; changeBg:bg.webp -next; setTransition: -target=bg-main -enter=shockwaveIn -next; -unlockCg:bg.webp -name=良い夜; // 解锁CG并赋予名称 +unlockCg:bg.webp -name=良い夜 -series=op; // 解锁CG并赋予名称 +unlockCg:WebGAL_New_Enter_Image.webp -name=Enter -series=op; // 解锁CG并赋予名称 changeFigure:stand.webp -left -enter=enter-from-left -animationFlag=on -eyesOpen=stand.webp -eyesClose=stand.webp -mouthOpen=open_mouth.webp -mouthHalfOpen=open_mouth.webp -mouthClose=stand.webp -next; miniAvatar:miniavatar.webp; {heroine}:欢迎使用 {engine}!这是一款全新的网页端视觉小说引擎。 -v1.wav -left; diff --git a/packages/webgal/src/Core/gameScripts/changeBg/index.ts b/packages/webgal/src/Core/gameScripts/changeBg/index.ts index cfcc01b7b..ad30b3c57 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/index.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/index.ts @@ -25,6 +25,7 @@ export const changeBg = (sentence: ISentence): IPerform => { const url = sentence.content; const unlockName = getStringArgByKey(sentence, 'unlockname') ?? ''; const series = getStringArgByKey(sentence, 'series') ?? 'default'; + const order = getNumberArgByKey(sentence, 'order') ?? 0; const transformString = getStringArgByKey(sentence, 'transform'); let duration = getNumberArgByKey(sentence, 'duration') ?? DEFAULT_BG_OUT_DURATION; const enterDuration = getNumberArgByKey(sentence, 'enterDuration') ?? duration; @@ -34,7 +35,7 @@ export const changeBg = (sentence: ISentence): IPerform => { const dispatch = webgalStore.dispatch; if (unlockName !== '') { - dispatch(unlockCgInUserData({ name: unlockName, url, series })); + dispatch(unlockCgInUserData({ name: unlockName, url, series, order })); const userDataState = webgalStore.getState().userData; localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); } diff --git a/packages/webgal/src/Core/gameScripts/unlockCg.ts b/packages/webgal/src/Core/gameScripts/unlockCg.ts index 3d8cca97d..9dcf694be 100644 --- a/packages/webgal/src/Core/gameScripts/unlockCg.ts +++ b/packages/webgal/src/Core/gameScripts/unlockCg.ts @@ -6,7 +6,7 @@ import { logger } from '@/Core/util/logger'; import localforage from 'localforage'; import { WebGAL } from '@/Core/WebGAL'; -import { getStringArgByKey } from '../util/getSentenceArg'; +import { getStringArgByKey, getNumberArgByKey } from '../util/getSentenceArg'; /** * 解锁cg @@ -16,8 +16,9 @@ export const unlockCg = (sentence: ISentence): IPerform => { const url = sentence.content; const name = getStringArgByKey(sentence, 'name') ?? sentence.content; const series = getStringArgByKey(sentence, 'series') ?? 'default'; - logger.info(`解锁CG:${name},路径:${url},所属系列:${series}`); - webgalStore.dispatch(unlockCgInUserData({ name, url, series })); + const order = getNumberArgByKey(sentence, 'order') ?? 0; + logger.info(`解锁CG:${name},路径:${url},所属系列:${series},排序:${order}`); + webgalStore.dispatch(unlockCgInUserData({ name, url, series, order })); const userDataState = webgalStore.getState().userData; localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); return createNonePerform(); diff --git a/packages/webgal/src/UI/Extra/ExtraCg.tsx b/packages/webgal/src/UI/Extra/ExtraCg.tsx index 8f610ebc7..698f920fa 100644 --- a/packages/webgal/src/UI/Extra/ExtraCg.tsx +++ b/packages/webgal/src/UI/Extra/ExtraCg.tsx @@ -1,23 +1,30 @@ import styles from '@/UI/Extra/extra.module.scss'; -import React from 'react'; +import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '@/store/store'; import { useValue } from '@/hooks/useValue'; import './extraCG_animation_List.scss'; -import { ExtraCgElement } from '@/UI/Extra/ExtraCgElement'; +import { ExtraCgElement, isVideoFile } from '@/UI/Extra/ExtraCgElement'; import useSoundEffect from '@/hooks/useSoundEffect'; +import { IAppreciationAsset } from '@/store/userDataInterface'; + +interface IExtraCgDisplayItem { + key: string; + name: string; + resources: IAppreciationAsset[]; +} export function ExtraCg() { const cgPerPage = 8; const extraState = useSelector((state: RootState) => state.userData.appreciationData); - const pageNumber = Math.ceil(extraState.cg.length / cgPerPage); - // const pageNumber = 10; + const groupedCgList = useMemo(() => buildGroupedCgList(extraState.cg), [extraState.cg]); + const pageNumber = Math.ceil(groupedCgList.length / cgPerPage); const currentPage = useValue(1); const { playSeEnter, playSeClick } = useSoundEffect(); // 开始生成立绘鉴赏的图片 const showCgList = []; - const len = extraState.cg.length; + const len = groupedCgList.length; for ( let i = (currentPage.value - 1) * cgPerPage; i < Math.min(len, (currentPage.value - 1) * cgPerPage + cgPerPage); @@ -27,11 +34,11 @@ export function ExtraCg() { const deg = Random(-5, 5); const temp = ( ); showCgList.push(temp); @@ -73,3 +80,42 @@ export function ExtraCg() { function Random(min: number, max: number) { return Math.round(Math.random() * (max - min)) + min; } + +function buildGroupedCgList(cgList: IAppreciationAsset[]): IExtraCgDisplayItem[] { + const groupedCgList: IExtraCgDisplayItem[] = []; + const seriesIndexMap = new Map(); + + cgList.forEach((cg, index) => { + // 视频不进行分组 + if (cg.series !== 'default' && !isVideoFile(cg.url)) { + const groupedIndex = seriesIndexMap.get(cg.series); + if (groupedIndex !== undefined) { + groupedCgList[groupedIndex].resources.push(cg); + return; + } + + seriesIndexMap.set(cg.series, groupedCgList.length); + groupedCgList.push({ + key: `series:${cg.series}`, + name: cg.name, + resources: [cg], + }); + return; + } + + groupedCgList.push({ + key: `default:${index}:${cg.url}`, + name: cg.name, + resources: [cg], + }); + }); + + return groupedCgList.map((groupedCg) => ({ + ...groupedCg, + resources: groupedCg.resources.length > 1 ? [...groupedCg.resources].sort(sortCgByOrder) : groupedCg.resources, + })); +} + +function sortCgByOrder(prev: IAppreciationAsset, next: IAppreciationAsset) { + return (prev.order ?? 0) - (next.order ?? 0); +} diff --git a/packages/webgal/src/UI/Extra/ExtraCgElement.tsx b/packages/webgal/src/UI/Extra/ExtraCgElement.tsx index 63a36287c..84d2c2dd1 100644 --- a/packages/webgal/src/UI/Extra/ExtraCgElement.tsx +++ b/packages/webgal/src/UI/Extra/ExtraCgElement.tsx @@ -1,37 +1,58 @@ import { useValue } from '@/hooks/useValue'; import styles from '@/UI/Extra/extra.module.scss'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import useSoundEffect from '@/hooks/useSoundEffect'; +import { IAppreciationAsset } from '@/store/userDataInterface'; + +export const isVideoFile = (url: string) => { + const extension = url.split('.').pop()?.toLowerCase() || ''; + return ['mp4', 'webm', 'mkv'].includes(extension); +} interface IProps { name: string; - resourceUrl: string; + resources: IAppreciationAsset[]; transformDeg: number; index: number; } export function ExtraCgElement(props: IProps) { const showFull = useValue(false); + const [currentResourceIndex, setCurrentResourceIndex] = useState(0); const { playSeEnter, playSeClick } = useSoundEffect(); + const previewResource = props.resources[0]; + const currentResource = props.resources[currentResourceIndex] ?? previewResource; // Determine if the resource is a video based on file extension const isVideo = useMemo(() => { - const extension = props.resourceUrl.split('.').pop()?.toLowerCase() || ''; - return ['mp4', 'webm', 'mkv'].includes(extension); - }, [props.resourceUrl]); + return isVideoFile(previewResource.url); + }, [previewResource.url]); // Determine if the resource is an image based on file extension const isImage = useMemo(() => { - const extension = props.resourceUrl.split('.').pop()?.toLowerCase() || ''; + const extension = previewResource.url.split('.').pop()?.toLowerCase() || ''; return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(extension); - }, [props.resourceUrl]); + }, [previewResource.url]); + const isStackPreview = props.resources.length > 1 && isImage; + const animationDelay = 100 + props.index * 100; + const stackResources = isStackPreview ? [...props.resources].reverse() : []; + + const getStackItemStyle = (index: number, length: number) => { + const offset = index - (length - 1) / 2; + return { + zIndex: index, + animationDelay: `${animationDelay + index * 140}ms`, + '--cg-stack-start-transform': `translate(${offset * 1.5}%, ${offset * -0.8}%) rotate(${offset * 2}deg)`, + '--cg-stack-end-transform': `translate(${offset * 6}%, ${offset * -3}%) rotate(${offset * 6}deg)`, + } as React.CSSProperties; + }; // Render media content based on resource type - const renderMedia = (fullScreen: boolean) => { + const renderMedia = (resourceUrl: string) => { if (isVideo) { return (