From 5458b7102062dfbf5831a4c3edb5ec543b051f41 Mon Sep 17 00:00:00 2001 From: K-bai Date: Tue, 21 Jan 2025 00:44:59 +0800 Subject: [PATCH 01/10] refactor(storyreader): create story selector for both text and live2d reader --- src/components/story-selector/AreaTalk.tsx | 194 +++ src/components/story-selector/CardStory.tsx | 196 +++ src/components/story-selector/CharaStory.tsx | 99 ++ src/components/story-selector/EventStory.tsx | 145 +++ .../story-selector/SpecialStory.tsx | 118 ++ .../story-selector/StorySelector.tsx | 363 ++++++ src/components/story-selector/UnitStory.tsx | 171 +++ .../storyreader-live2d/StoryReaderLive2D.tsx | 1054 +--------------- src/pages/storyreader/StoryReader.tsx | 1057 +---------------- 9 files changed, 1356 insertions(+), 2041 deletions(-) create mode 100644 src/components/story-selector/AreaTalk.tsx create mode 100644 src/components/story-selector/CardStory.tsx create mode 100644 src/components/story-selector/CharaStory.tsx create mode 100644 src/components/story-selector/EventStory.tsx create mode 100644 src/components/story-selector/SpecialStory.tsx create mode 100644 src/components/story-selector/StorySelector.tsx create mode 100644 src/components/story-selector/UnitStory.tsx diff --git a/src/components/story-selector/AreaTalk.tsx b/src/components/story-selector/AreaTalk.tsx new file mode 100644 index 00000000..3d19ab81 --- /dev/null +++ b/src/components/story-selector/AreaTalk.tsx @@ -0,0 +1,194 @@ +import React, { useEffect } from "react"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; + +import { realityAreaWorldmap, useCachedData } from "../../utils"; +import { useRootStore } from "../../stores/root"; +import { IArea, IActionSet, ICharacter2D, ServerRegion } from "../../types.d"; + +import { charaIcons } from "../../utils/resources"; + +import { Grid, CardContent, Card, Avatar, styled } from "@mui/material"; + +import LinkNoDecorationAlsoNoHover from "../styled/LinkNoDecorationAlsoHover"; +const CardSelect = styled(Card)` + &:hover { + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + } +`; + +import { ContentTrans } from "../helpers/ContentTrans"; +import ImageWrapper from "../helpers/ImageWrapper"; + +const AreaTalk: React.FC<{ + onSetStory: (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => void; +}> = ({ onSetStory }) => { + const { path } = useRouteMatch(); + const [areas] = useCachedData("areas"); + const [actionSets] = useCachedData("actionSets"); + const [chara2Ds] = useCachedData("character2ds"); + const { region } = useRootStore(); + + const leafMatch = useRouteMatch({ + path: `${path}/:areaId/:actionSetId`, + strict: true, + }); + useEffect(() => { + if (leafMatch) { + onSetStory({ + storyType: "areaTalk", + storyId: leafMatch.url, + region, + }); + } else { + onSetStory(); + } + }, [leafMatch, onSetStory, region]); + + return ( + + + + {!!areas && + areas + .filter((area) => area.label) + .map((area) => ( + + + + + + + + + + + + + ))} + {!!areas && + areas + .filter((area) => area.areaType === "spirit_world" && !area.label) + .map((area) => ( + + + + + + + + + + + + + ))} + {!!areas && + areas + .filter((area) => area.areaType === "reality_world") + .map((area, idx) => ( + + + + + + + + + + + + + ))} + + + + {({ match }) => { + const areaId = match?.params.areaId; + if (areaId && areas) { + const area = areas.find((area) => area.id === Number(areaId)); + if (area && actionSets && chara2Ds) { + return ( + + {actionSets + .filter((as) => as.areaId === Number(areaId)) + .map((actionSet) => ( + + + + + + {actionSet.characterIds.map((charaId) => { + const characterId = chara2Ds.find( + (c2d) => c2d.id === charaId + )!.characterId; + return ( + + + + ); + })} + + + + + + ))} + + ); + } + } + }} + + + ); +}; +export default AreaTalk; diff --git a/src/components/story-selector/CardStory.tsx b/src/components/story-selector/CardStory.tsx new file mode 100644 index 00000000..383c1e3d --- /dev/null +++ b/src/components/story-selector/CardStory.tsx @@ -0,0 +1,196 @@ +import React, { useEffect } from "react"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; + +import { useCachedData } from "../../utils"; +import { useRootStore } from "../../stores/root"; +import type { + ICharaProfile, + ICardInfo, + ICardEpisode, + ServerRegion, +} from "../../types.d"; + +import { charaIcons } from "../../utils/resources"; + +import { Grid, CardContent, Card, styled } from "@mui/material"; + +import LinkNoDecorationAlsoNoHover from "../styled/LinkNoDecorationAlsoHover"; +const CardSelect = styled(Card)` + &:hover { + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + } +`; + +import { ContentTrans, CharaNameTrans } from "../helpers/ContentTrans"; +import ImageWrapper from "../helpers/ImageWrapper"; + +const CardStory: React.FC<{ + onSetStory: (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => void; +}> = ({ onSetStory }) => { + const { path } = useRouteMatch(); + const [characterProfiles] = useCachedData("characterProfiles"); + const [cards] = useCachedData("cards"); + const [cardEpisodes] = useCachedData("cardEpisodes"); + const { + region, + settings: { isShowSpoiler }, + } = useRootStore(); + + const leafMatch = useRouteMatch({ + path: `${path}/:charaId/:cardId/:episodeId`, + strict: true, + }); + useEffect(() => { + if (leafMatch) { + onSetStory({ + storyType: "cardStory", + storyId: leafMatch.url, + region, + }); + } else { + onSetStory(); + } + }, [leafMatch, onSetStory, region]); + + return ( + + + + {!!characterProfiles && + characterProfiles.map((character) => ( + + + + + + + + + + + + + + + + + ))} + + + + {({ match }) => { + const charaId = match?.params.charaId; + if (charaId && cards) { + const filteredCards = cards.filter( + (card) => + card.characterId === Number(charaId) && + (isShowSpoiler || + (card.releaseAt ?? card.archivePublishedAt!) <= + new Date().getTime()) + ); + if (filteredCards.length) { + return ( + + {filteredCards.map((card) => ( + + + + + + + + + + + + + ))} + + ); + } + } + }} + + + {({ match }) => { + const cardId = match?.params.cardId; + if (cardId && cardEpisodes) { + const episodes = cardEpisodes.filter( + (ce) => ce.cardId === Number(cardId) + ); + if (episodes.length) { + return ( + + {episodes.map((episode) => ( + + + + + + + + + + ))} + + ); + } + } + }} + + + ); +}; +export default CardStory; diff --git a/src/components/story-selector/CharaStory.tsx b/src/components/story-selector/CharaStory.tsx new file mode 100644 index 00000000..8ec26fd3 --- /dev/null +++ b/src/components/story-selector/CharaStory.tsx @@ -0,0 +1,99 @@ +import React, { useEffect } from "react"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; + +import { useCachedData } from "../../utils"; +import { useRootStore } from "../../stores/root"; +import { ICharaProfile, ServerRegion } from "../../types.d"; + +import { charaIcons } from "../../utils/resources"; + +import { Grid, CardContent, Card, styled } from "@mui/material"; + +import LinkNoDecorationAlsoNoHover from "../styled/LinkNoDecorationAlsoHover"; +const CardSelect = styled(Card)` + &:hover { + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + } +`; + +import { CharaNameTrans } from "../helpers/ContentTrans"; +import ImageWrapper from "../helpers/ImageWrapper"; + +const CharaStory: React.FC<{ + onSetStory: (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => void; +}> = ({ onSetStory }) => { + const { path } = useRouteMatch(); + const [characterProfiles] = useCachedData("characterProfiles"); + const { region } = useRootStore(); + + const leafMatch = useRouteMatch({ + path: `${path}/:charaId`, + strict: true, + }); + useEffect(() => { + if (leafMatch) { + onSetStory({ + storyType: "charaStory", + storyId: leafMatch.url, + region, + }); + } else { + onSetStory(); + } + }, [leafMatch, onSetStory, region]); + + return ( + + + + {!!characterProfiles && + characterProfiles.map((character) => ( + + + + + + + + + + + + + + + + + ))} + + + + ); +}; +export default CharaStory; diff --git a/src/components/story-selector/EventStory.tsx b/src/components/story-selector/EventStory.tsx new file mode 100644 index 00000000..d523c5a4 --- /dev/null +++ b/src/components/story-selector/EventStory.tsx @@ -0,0 +1,145 @@ +import React, { useEffect } from "react"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; + +import { useCachedData } from "../../utils"; +import { useRootStore } from "../../stores/root"; +import type { IEventInfo, IEventStory, ServerRegion } from "../../types.d"; + +import { Grid, CardContent, Card, Typography, styled } from "@mui/material"; + +import LinkNoDecorationAlsoNoHover from "../styled/LinkNoDecorationAlsoHover"; +const CardSelect = styled(Card)` + &:hover { + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + } +`; + +import { ContentTrans } from "../helpers/ContentTrans"; +import ImageWrapper from "../helpers/ImageWrapper"; + +const EventStory: React.FC<{ + onSetStory: (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => void; +}> = ({ onSetStory }) => { + const { path } = useRouteMatch(); + const [events] = useCachedData("events"); + const [eventStories] = useCachedData("eventStories"); + const { + region, + settings: { isShowSpoiler }, + } = useRootStore(); + + const leafMatch = useRouteMatch({ + path: `${path}/:eventId/:episodeNo`, + strict: true, + }); + useEffect(() => { + if (leafMatch) { + onSetStory({ + storyType: "eventStory", + storyId: leafMatch.url, + region, + }); + } else { + onSetStory(); + } + }, [leafMatch, onSetStory, region]); + + return ( + + + + {!!events && + (isShowSpoiler + ? events + : events.filter((e) => e.startAt <= new Date().getTime()) + ) + .slice() + .reverse() + .map((ev) => ( + + + + + + + + + + + + + ))} + + + + {({ match }) => { + const eventId = match?.params.eventId; + if (eventId && eventStories) { + const chapter = eventStories.find( + (es) => es.eventId === Number(eventId) + ); + if (chapter) { + return ( + + {!!chapter.outline && ( + + + {chapter.outline} + + + )} + {chapter.eventStoryEpisodes.map((episode) => ( + + + + + + + + + + + + + ))} + + ); + } + } + }} + + + ); +}; +export default EventStory; diff --git a/src/components/story-selector/SpecialStory.tsx b/src/components/story-selector/SpecialStory.tsx new file mode 100644 index 00000000..d2eb45e1 --- /dev/null +++ b/src/components/story-selector/SpecialStory.tsx @@ -0,0 +1,118 @@ +import React, { useEffect } from "react"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; + +import { useCachedData } from "../../utils"; +import { useRootStore } from "../../stores/root"; +import { ISpecialStory, ServerRegion } from "../../types.d"; + +import { Grid, CardContent, Card, Typography, styled } from "@mui/material"; + +import LinkNoDecorationAlsoNoHover from "../styled/LinkNoDecorationAlsoHover"; +const CardSelect = styled(Card)` + &:hover { + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + } +`; + +const SpecialStory: React.FC<{ + onSetStory: (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => void; +}> = ({ onSetStory }) => { + const { path } = useRouteMatch(); + const [specialStories] = useCachedData("specialStories"); + const { region } = useRootStore(); + + const leafMatch = useRouteMatch({ + path: `${path}/:storyId/:episodeNo`, + strict: true, + }); + useEffect(() => { + if (leafMatch) { + onSetStory({ + storyType: "specialStory", + storyId: leafMatch.url, + region, + }); + } else { + onSetStory(); + } + }, [leafMatch, onSetStory, region]); + + return ( + + + + {!!specialStories && + specialStories + .slice() + .reverse() + .map((sp) => ( + + + + + {/* */} + + {sp.title} + + + + + + ))} + + + + {({ match }) => { + const storyId = match?.params.storyId; + if (storyId && specialStories) { + const chapter = specialStories.find( + (sp) => sp.id === Number(storyId) + ); + if (chapter) { + return ( + + {chapter.episodes.map((episode) => ( + + + + + {/* */} + + {episode.title} + + + + + + ))} + + ); + } + } + }} + + + ); +}; +export default SpecialStory; diff --git a/src/components/story-selector/StorySelector.tsx b/src/components/story-selector/StorySelector.tsx new file mode 100644 index 00000000..a0db4249 --- /dev/null +++ b/src/components/story-selector/StorySelector.tsx @@ -0,0 +1,363 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; + +import type { + ServerRegion, + IUnitProfile, + IUnitStory, + IEventStory, + IEventInfo, + ICharaProfile, + ICardEpisode, + ICardInfo, + IArea, + ISpecialStory, +} from "../../types.d"; + +import { useCachedData } from "../../utils"; +import { useAssetI18n, useCharaName } from "../../utils/i18n"; +import { + Breadcrumbs, + Grid, + Typography, + Card, + CardContent, + styled, +} from "@mui/material"; +import LinkNoDecoration from "../../components/styled/LinkNoDecoration"; +import LinkNoDecorationAlsoNoHover from "../../components/styled/LinkNoDecorationAlsoHover"; +const CardSelect = styled(Card)` + &:hover { + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + } +`; + +import EventStory from "./EventStory"; +import UnitStory from "./UnitStory"; +import CharaStory from "./CharaStory"; +import CardStory from "./CardStory"; +import AreaTalk from "./AreaTalk"; +import SpecialStory from "./SpecialStory"; + +const StorySelector: React.FC<{ + onSetStory: (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => void; +}> = ({ onSetStory }) => { + const { t } = useTranslation(); + const { getTranslated } = useAssetI18n(); + const getCharaName = useCharaName(); + const { path } = useRouteMatch(); + + const [unitProfiles] = useCachedData("unitProfiles"); + const [unitStories] = useCachedData("unitStories"); + const [eventStories] = useCachedData("eventStories"); + const [events] = useCachedData("events"); + const [characterProfiles] = useCachedData("characterProfiles"); + const [cardEpisodes] = useCachedData("cardEpisodes"); + const [cards] = useCachedData("cards"); + const [areas] = useCachedData("areas"); + const [specialStories] = useCachedData("specialStories"); + + const handleSetStory = onSetStory; + + const catagory: { + [key: string]: { + breadcrumbName: string; + path: string; + disabled: boolean; + }; + } = useMemo( + () => ({ + eventStory: { + breadcrumbName: t("story_reader:selectValue.eventStory"), + path: "/eventStory", + disabled: false, + }, + unitStory: { + breadcrumbName: t("story_reader:selectValue.unitStory"), + path: "/unitStory", + disabled: false, + }, + charaStory: { + breadcrumbName: t("story_reader:selectValue.charaStory"), + path: "/charaStory", + disabled: false, + }, + cardStory: { + breadcrumbName: t("story_reader:selectValue.cardStory"), + path: "/cardStory", + disabled: false, + }, + areaTalk: { + breadcrumbName: t("story_reader:selectValue.areaTalk"), + path: "/areaTalk", + disabled: false, + }, + specialStory: { + breadcrumbName: t("story_reader:selectValue.specialStory"), + path: "/specialStory", + disabled: false, + }, + liveTalk: { + breadcrumbName: t("story_reader:selectValue.liveTalk"), + path: "/liveTalk", + disabled: true, + }, + }), + [t] + ); + + return ( + <> + + {({ location }) => { + const pathnames = location.pathname.split("/").filter((x) => x); + console.log(pathnames); + return ( + + {pathnames.map((pathname, idx) => { + const last = idx === pathnames.length - 1; + const to = `/${pathnames.slice(0, idx + 1).join("/")}`; + + let name = ""; + if (idx === 0) { + name = t("common:storyReader"); + } else if ( + idx === 1 && + Object.keys(catagory).includes(pathname) + ) { + name = catagory[pathname].breadcrumbName; + } else if (idx >= 2) { + switch (pathnames[1]) { + case "eventStory": + if (events && idx === 2) { + const found = events.find( + (ev) => ev.id === Number(pathname) + ); + if (found) { + name = getTranslated( + `event_name:${pathname}`, + found.name + ); + } + } + if (eventStories && idx === 3) { + const found = eventStories.find( + (es) => es.eventId === Number(pathnames[2]) + ); + if (found) { + const episode = found.eventStoryEpisodes.find( + (ese) => ese.episodeNo === Number(pathname) + ); + if (episode) { + name = getTranslated( + `event_story_episode_title:${episode.eventStoryId}-${episode.episodeNo}`, + episode.title + ); + } + } + } + break; + case "unitStory": + if (unitProfiles && idx === 2) { + const found = unitProfiles.find( + (unit) => unit.unit === pathname + ); + if (found) { + name = getTranslated( + `unit_profile:${found.unit}.name`, + found.unitName + ); + } + } + if (unitStories) { + const found = unitStories.find( + (us) => us.unit === pathnames[2] + ); + if (found && idx === 3) { + const chapter = found.chapters.find( + (cp) => cp.chapterNo === Number(pathname) + ); + if (chapter) { + name = getTranslated( + `unit_story_chapter_title:${chapter.unit}-${chapter.chapterNo}`, + chapter.title + ); + } + } + if (found && idx === 4) { + const chapter = found.chapters.find( + (cp) => cp.chapterNo === Number(pathnames[3]) + ); + if (chapter) { + const episode = chapter.episodes.find( + (ep) => ep.episodeNo === Number(pathname) + ); + if (episode) { + name = getTranslated( + `unit_story_episode_title:${episode.unit}-${episode.chapterNo}-${episode.episodeNo}`, + episode.title + ); + } + } + } + } + break; + case "charaStory": + if (characterProfiles && idx === 2) { + const found = characterProfiles.find( + (cp) => cp.characterId === Number(pathname) + ); + if (found) { + name = getCharaName(found.characterId) || ""; + } + } + break; + case "cardStory": + if (characterProfiles && idx === 2) { + const found = characterProfiles.find( + (cp) => cp.characterId === Number(pathname) + ); + if (found) { + name = getCharaName(found.characterId) || ""; + } + } + if (cards && idx === 3) { + const card = cards.find( + (card) => card.id === Number(pathname) + ); + if (card) { + name = getTranslated( + `card_prefix:${card.id}`, + card.prefix + ); + } + } + if (cardEpisodes && idx === 4) { + const episode = cardEpisodes.find( + (cep) => cep.id === Number(pathname) + ); + if (episode) { + name = getTranslated( + `card_episode_title:${episode.title}`, + episode.title + ); + } + } + break; + case "areaTalk": + if (areas && idx === 2) { + const area = areas.find( + (area) => area.id === Number(pathname) + ); + if (area) { + name = getTranslated( + `area_name:${area.id}`, + area.name + ); + } + } + if (idx === 3) { + name = pathname; + } + break; + case "specialStory": + if (specialStories) { + if (idx === 2) { + const chapter = specialStories.find( + (sp) => sp.id === Number(pathname) + ); + if (chapter) { + name = chapter.title; + } + } else if (idx === 3) { + const chapter = specialStories.find( + (sp) => sp.id === Number(pathnames[2]) + ); + if (chapter) { + const episode = chapter.episodes.find( + (ep) => ep.episodeNo === Number(pathname) + ); + if (episode) { + name = episode.title; + } + } + } + } + } + } + + return last ? ( + + {name} + + ) : ( + + {name} + + ); + })} + + ); + }} + + + + + {Object.entries(catagory).map(([key, c]) => { + if (c.disabled) { + return ( + + + + + {c.breadcrumbName} + + + + + ); + } else { + return ( + + + + + {c.breadcrumbName} + + + + + ); + } + })} + + + + + + + + + + + + + + + + + + + + + + + ); +}; +export default StorySelector; diff --git a/src/components/story-selector/UnitStory.tsx b/src/components/story-selector/UnitStory.tsx new file mode 100644 index 00000000..c26a0ead --- /dev/null +++ b/src/components/story-selector/UnitStory.tsx @@ -0,0 +1,171 @@ +import React, { useEffect } from "react"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; + +import { useCachedData } from "../../utils"; +import { useRootStore } from "../../stores/root"; + +import { UnitLogoMap } from "../../utils/resources"; + +import type { IUnitStory, IUnitProfile, ServerRegion } from "../../types.d"; + +import { Grid, CardContent, Card, styled } from "@mui/material"; + +import LinkNoDecorationAlsoNoHover from "../styled/LinkNoDecorationAlsoHover"; +const CardSelect = styled(Card)` + &:hover { + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + } +`; + +import { ContentTrans } from "../helpers/ContentTrans"; +import ImageWrapper from "../helpers/ImageWrapper"; + +const UnitStory: React.FC<{ + onSetStory: (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => void; +}> = ({ onSetStory }) => { + const { path } = useRouteMatch(); + const [unitProfiles] = useCachedData("unitProfiles"); + const [unitStories] = useCachedData("unitStories"); + const { region } = useRootStore(); + + const leafMatch = useRouteMatch({ + path: `${path}/:unit/:chapterNo/:episodeNo`, + strict: true, + }); + useEffect(() => { + if (leafMatch) { + onSetStory({ + storyType: "unitStory", + storyId: leafMatch.url, + region, + }); + } else { + onSetStory(); + } + }, [leafMatch, onSetStory, region]); + + return ( + + + + {!!unitProfiles && + unitProfiles.map((unit) => ( + + + + + + + + + + + + + ))} + + + + {({ match }) => { + const unit = match?.params.unit; + if (unit && unitStories) { + const stories = unitStories.find((us) => us.unit === unit); + if (stories) { + return ( + + {stories.chapters.map((chapter) => ( + + + + + + + + + + ))} + + ); + } + } + }} + + + {({ match }) => { + const unit = match?.params.unit; + const chapterNo = match?.params.chapterNo; + if (unit && chapterNo && unitStories) { + const stories = unitStories.find((us) => us.unit === unit); + if (stories) { + const chapter = stories.chapters.find( + (s) => s.chapterNo === Number(chapterNo) + ); + if (chapter) { + return ( + + {chapter.episodes.map((episode) => ( + + + + + + + + + + + + + ))} + + ); + } + } + } + }} + + + ); +}; +export default UnitStory; diff --git a/src/pages/storyreader-live2d/StoryReaderLive2D.tsx b/src/pages/storyreader-live2d/StoryReaderLive2D.tsx index 4d3abe33..9e0b9038 100644 --- a/src/pages/storyreader-live2d/StoryReaderLive2D.tsx +++ b/src/pages/storyreader-live2d/StoryReaderLive2D.tsx @@ -1,1028 +1,44 @@ -import { - Breadcrumbs, - Grid, - Typography, - Card, - CardContent, - Avatar, - styled, - Alert, -} from "@mui/material"; -import React, { Fragment, useMemo } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Route, Switch, useRouteMatch } from "react-router-dom"; -import { - IActionSet, - IArea, - ICardEpisode, - ICardInfo, - ICharacter2D, - ICharaProfile, - IEventInfo, - IEventStory, - ISpecialStory, - IUnitProfile, - IUnitStory, -} from "../../types"; -import { realityAreaWorldmap, useCachedData } from "../../utils"; -import { useAssetI18n, useCharaName } from "../../utils/i18n"; -import { charaIcons, UnitLogoMap } from "../../utils/resources"; -import { - CharaNameTrans, - ContentTrans, -} from "../../components/helpers/ContentTrans"; -import ImageWrapper from "../../components/helpers/ImageWrapper"; -import { observer } from "mobx-react-lite"; -import { useRootStore } from "../../stores/root"; -import TypographyHeader from "../../components/styled/TypographyHeader"; -import LinkNoDecoration from "../../components/styled/LinkNoDecoration"; -import LinkNoDecorationAlsoNoHover from "../../components/styled/LinkNoDecorationAlsoHover"; -import StoryReaderLive2DContent from "./StoryReaderLive2DContent"; - -const CardSelect = styled(Card)` - &:hover { - cursor: pointer; - border: 1px solid rgba(255, 255, 255, 0.12); - } -`; -const StoryReaderLive2D: React.FC = observer(() => { - const { t } = useTranslation(); - const { getTranslated } = useAssetI18n(); - const getCharaName = useCharaName(); - const { path } = useRouteMatch(); - const { - region, - settings: { isShowSpoiler }, - } = useRootStore(); +import type { ServerRegion } from "../../types.d"; - const [unitProfiles] = useCachedData("unitProfiles"); - const [unitStories] = useCachedData("unitStories"); - const [eventStories] = useCachedData("eventStories"); - const [events] = useCachedData("events"); - const [characterProfiles] = useCachedData("characterProfiles"); - const [cardEpisodes] = useCachedData("cardEpisodes"); - const [cards] = useCachedData("cards"); - const [areas] = useCachedData("areas"); - const [actionSets] = useCachedData("actionSets"); - const [chara2Ds] = useCachedData("character2ds"); - const [specialStories] = useCachedData("specialStories"); +import StorySelector from "../../components/story-selector/StorySelector"; +import StoryReaderLive2DContent from "./StoryReaderLive2DContent"; +import TypographyHeader from "../../components/styled/TypographyHeader"; - const breadcrumbNameMap: { [key: string]: string } = useMemo( - () => ({ - "storyreader-live2d": t("common:storyReader"), - eventStory: t("story_reader:selectValue.eventStory"), - unitStory: t("story_reader:selectValue.unitStory"), - charaStory: t("story_reader:selectValue.charaStory"), - cardStory: t("story_reader:selectValue.cardStory"), - areaTalk: t("story_reader:selectValue.areaTalk"), - liveTalk: t("story_reader:selectValue.liveTalk"), - specialStory: t("story_reader:selectValue.special"), - }), - [t] - ); +const StoryReaderLive2D: React.FC = () => { + const { t } = useTranslation(); + const [story, setStory] = useState<{ + storyType: string; + storyId: string; + region: ServerRegion; + }>(); + const handleSetStory = (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => { + if (!data) setStory(undefined); + else if (data && !story) setStory(data); + else if (data && story && data.storyId !== story.storyId) setStory(data); + }; return ( - - {t("common:storyReader")} - ({ margin: theme.spacing(1, 0) })} - > - {t("common:betaIndicator")} - - - {({ location }) => { - const pathnames = location.pathname.split("/").filter((x) => x); - // console.log(pathnames); - - return ( - - {pathnames.map((pathname, idx) => { - const last = idx === pathnames.length - 1; - const to = `/${pathnames.slice(0, idx + 1).join("/")}`; - - let name = breadcrumbNameMap[pathname]; - if (!name && idx >= 2) { - switch (pathnames[1]) { - case "eventStory": - if (events && idx === 2) { - const found = events.find( - (ev) => ev.id === Number(pathname) - ); - if (found) { - name = getTranslated( - `event_name:${pathname}`, - found.name - ); - } - } - if (eventStories && idx === 3) { - const found = eventStories.find( - (es) => es.eventId === Number(pathnames[2]) - ); - if (found) { - const episode = found.eventStoryEpisodes.find( - (ese) => ese.episodeNo === Number(pathname) - ); - if (episode) { - name = getTranslated( - `event_story_episode_title:${episode.eventStoryId}-${episode.episodeNo}`, - episode.title - ); - } - } - } - break; - case "unitStory": - if (unitProfiles && idx === 2) { - const found = unitProfiles.find( - (unit) => unit.unit === pathname - ); - if (found) { - name = getTranslated( - `unit_profile:${found.unit}.name`, - found.unitName - ); - } - } - if (unitStories) { - const found = unitStories.find( - (us) => us.unit === pathnames[2] - ); - if (found && idx === 3) { - const chapter = found.chapters.find( - (cp) => cp.chapterNo === Number(pathname) - ); - if (chapter) { - name = getTranslated( - `unit_story_chapter_title:${chapter.unit}-${chapter.chapterNo}`, - chapter.title - ); - } - } - if (found && idx === 4) { - const chapter = found.chapters.find( - (cp) => cp.chapterNo === Number(pathnames[3]) - ); - if (chapter) { - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(pathname) - ); - if (episode) { - name = getTranslated( - `unit_story_episode_title:${episode.unit}-${episode.chapterNo}-${episode.episodeNo}`, - episode.title - ); - } - } - } - } - break; - case "charaStory": - if (characterProfiles && idx === 2) { - const found = characterProfiles.find( - (cp) => cp.characterId === Number(pathname) - ); - if (found) { - name = getCharaName(found.characterId) || ""; - } - } - break; - case "cardStory": - if (characterProfiles && idx === 2) { - const found = characterProfiles.find( - (cp) => cp.characterId === Number(pathname) - ); - if (found) { - name = getCharaName(found.characterId) || ""; - } - } - if (cards && idx === 3) { - const card = cards.find( - (card) => card.id === Number(pathname) - ); - if (card) { - name = getTranslated( - `card_prefix:${card.id}`, - card.prefix - ); - } - } - if (cardEpisodes && idx === 4) { - const episode = cardEpisodes.find( - (cep) => cep.id === Number(pathname) - ); - if (episode) { - name = getTranslated( - `card_episode_title:${episode.title}`, - episode.title - ); - } - } - break; - case "areaTalk": - if (areas && idx === 2) { - const area = areas.find( - (area) => area.id === Number(pathname) - ); - if (area) { - name = getTranslated( - `area_name:${area.id}`, - area.name - ); - } - } - if (idx === 3) { - name = pathname; - } - break; - case "specialStory": - if (specialStories) { - if (idx === 2) { - const chapter = specialStories.find( - (sp) => sp.id === Number(pathname) - ); - if (chapter) { - name = chapter.title; - } - } else if (idx === 3) { - const chapter = specialStories.find( - (sp) => sp.id === Number(pathnames[2]) - ); - if (chapter) { - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(pathname) - ); - if (episode) { - name = episode.title; - } - } - } - } - } - } - - return last ? ( - - {name} - - ) : ( - - {name} - - ); - })} - - ); - }} - -
- - - - - - - - - {t("story_reader:selectValue.eventStory")} - - - - - - - - - - - {t("story_reader:selectValue.unitStory")} - - - - - - - - - - - {t("story_reader:selectValue.charaStory")} - - - - - - - - - - - {t("story_reader:selectValue.cardStory")} - - - - - - - - - - - {t("story_reader:selectValue.areaTalk")} - - - - - - - - - - - {t("story_reader:selectValue.special")} - - - - - - - - - - {t("story_reader:selectValue.liveTalk")} - - - - - - - - - {!!events && - (isShowSpoiler - ? events - : events.filter((e) => e.startAt <= new Date().getTime()) - ) - .slice() - .reverse() - .map((ev) => ( - - - - - - - - - - - - - ))} - - - - {({ match }) => { - const eventId = match?.params.eventId; - if (eventId && eventStories) { - const chapter = eventStories.find( - (es) => es.eventId === Number(eventId) - ); - if (chapter) { - return ( - - {!!chapter.outline && ( - - - {chapter.outline} - - - )} - {chapter.eventStoryEpisodes.map((episode) => ( - - - - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!unitProfiles && - unitProfiles.map((unit) => ( - - - - - - - - - - - - - ))} - - - - {({ match }) => { - // console.log(match); - const unit = match?.params.unit; - if (unit && unitStories) { - const stories = unitStories.find((us) => us.unit === unit); - if (stories) { - return ( - - {stories.chapters.map((chapter) => ( - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => { - // console.log(match); - const unit = match?.params.unit; - const chapterNo = match?.params.chapterNo; - if (unit && chapterNo && unitStories) { - const stories = unitStories.find((us) => us.unit === unit); - if (stories) { - const chapter = stories.chapters.find( - (s) => s.chapterNo === Number(chapterNo) - ); - if (chapter) { - return ( - - {chapter.episodes.map((episode) => ( - - - - - - - - - - - - - ))} - - ); - } - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!characterProfiles && - characterProfiles.map((character) => ( - - - - - - - - - - - - - - - - - ))} - - - - - - {!!characterProfiles && - characterProfiles.map((character) => ( - - - - - - - - - - - - - - - - - ))} - - - - {({ match }) => { - const charaId = match?.params.charaId; - if (charaId && cards) { - const filteredCards = cards.filter( - (card) => - card.characterId === Number(charaId) && - (isShowSpoiler || - (card.releaseAt ?? card.archivePublishedAt!) <= - new Date().getTime()) - ); - if (filteredCards.length) { - return ( - - {filteredCards.map((card) => ( - - - - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => { - // console.log(match); - const cardId = match?.params.cardId; - if (cardId && cardEpisodes) { - const episodes = cardEpisodes.filter( - (ce) => ce.cardId === Number(cardId) - ); - if (episodes.length) { - return ( - - {episodes.map((episode) => ( - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!areas && - areas - .filter((area) => area.label) - .map((area) => ( - - - - - - - - - - - - - ))} - {!!areas && - areas - .filter( - (area) => area.areaType === "spirit_world" && !area.label - ) - .map((area) => ( - - - - - - - - - - - - - ))} - {!!areas && - areas - .filter((area) => area.areaType === "reality_world") - .map((area, idx) => ( - - - - - - - - - - - - - ))} - - - - {({ match }) => { - const areaId = match?.params.areaId; - if (areaId && areas) { - const area = areas.find((area) => area.id === Number(areaId)); - if (area && actionSets && chara2Ds) { - return ( - - {actionSets - .filter((as) => as.areaId === Number(areaId)) - .map((actionSet) => ( - - - - - - {actionSet.characterIds.map((charaId) => { - const characterId = chara2Ds.find( - (c2d) => c2d.id === charaId - )!.characterId; - return ( - - - - ); - })} - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!specialStories && - specialStories - .slice() - .reverse() - .map((sp) => ( - - - - - {/* */} - - {sp.title} - - - - - - ))} - - - - {({ match }) => { - const storyId = match?.params.storyId; - if (storyId && specialStories) { - const chapter = specialStories.find( - (sp) => sp.id === Number(storyId) - ); - if (chapter) { - return ( - - {chapter.episodes.map((episode) => ( - - - - - {/* */} - - {episode.title} - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - -
+ <> +
+ {t("common:storyReader")} +
+ + {story && ( + + )} + ); -}); +}; export default StoryReaderLive2D; diff --git a/src/pages/storyreader/StoryReader.tsx b/src/pages/storyreader/StoryReader.tsx index 4aa86f80..390eaa90 100644 --- a/src/pages/storyreader/StoryReader.tsx +++ b/src/pages/storyreader/StoryReader.tsx @@ -1,1031 +1,44 @@ -import { - Breadcrumbs, - Grid, - Typography, - Card, - CardContent, - Avatar, - styled, -} from "@mui/material"; -import React, { Fragment, useMemo } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Route, Switch, useRouteMatch } from "react-router-dom"; -import { - IActionSet, - IArea, - ICardEpisode, - ICardInfo, - ICharacter2D, - ICharaProfile, - IEventInfo, - IEventStory, - ISpecialStory, - IUnitProfile, - IUnitStory, -} from "../../types.d"; -import { realityAreaWorldmap, useCachedData } from "../../utils"; -import { useAssetI18n, useCharaName } from "../../utils/i18n"; -import { charaIcons, UnitLogoMap } from "../../utils/resources"; -import { - CharaNameTrans, - ContentTrans, -} from "../../components/helpers/ContentTrans"; -import ImageWrapper from "../../components/helpers/ImageWrapper"; + +import type { ServerRegion } from "../../types.d"; + +import StorySelector from "../../components/story-selector/StorySelector"; import StoryReaderContent from "./StoryReaderContent"; -import { observer } from "mobx-react-lite"; -import { useRootStore } from "../../stores/root"; import TypographyHeader from "../../components/styled/TypographyHeader"; -import LinkNoDecoration from "../../components/styled/LinkNoDecoration"; -import LinkNoDecorationAlsoNoHover from "../../components/styled/LinkNoDecorationAlsoHover"; - -const CardSelect = styled(Card)` - &:hover { - cursor: pointer; - border: 1px solid rgba(255, 255, 255, 0.12); - } -`; -const StoryReader: React.FC = observer(() => { +const StoryReaderLive2D: React.FC = () => { const { t } = useTranslation(); - const { getTranslated } = useAssetI18n(); - const getCharaName = useCharaName(); - const { path } = useRouteMatch(); - const { - region, - settings: { isShowSpoiler }, - } = useRootStore(); - - const [unitProfiles] = useCachedData("unitProfiles"); - const [unitStories] = useCachedData("unitStories"); - const [eventStories] = useCachedData("eventStories"); - const [events] = useCachedData("events"); - const [characterProfiles] = useCachedData("characterProfiles"); - const [cardEpisodes] = useCachedData("cardEpisodes"); - const [cards] = useCachedData("cards"); - const [areas] = useCachedData("areas"); - const [actionSets] = useCachedData("actionSets"); - const [chara2Ds] = useCachedData("character2ds"); - const [specialStories] = useCachedData("specialStories"); - - const breadcrumbNameMap: { [key: string]: string } = useMemo( - () => ({ - storyreader: t("common:storyReader"), - eventStory: t("story_reader:selectValue.eventStory"), - unitStory: t("story_reader:selectValue.unitStory"), - charaStory: t("story_reader:selectValue.charaStory"), - cardStory: t("story_reader:selectValue.cardStory"), - areaTalk: t("story_reader:selectValue.areaTalk"), - liveTalk: t("story_reader:selectValue.liveTalk"), - specialStory: t("story_reader:selectValue.special"), - }), - [t] - ); + const [story, setStory] = useState<{ + storyType: string; + storyId: string; + region: ServerRegion; + }>(); + const handleSetStory = (data?: { + storyType: string; + storyId: string; + region: ServerRegion; + }) => { + if (!data) setStory(undefined); + else if (data && !story) setStory(data); + else if (data && story && data.storyId !== story.storyId) setStory(data); + }; return ( - - {t("common:storyReader")} - - {({ location }) => { - const pathnames = location.pathname.split("/").filter((x) => x); - // console.log(pathnames); - - return ( - - {pathnames.map((pathname, idx) => { - const last = idx === pathnames.length - 1; - const to = `/${pathnames.slice(0, idx + 1).join("/")}`; - - let name = breadcrumbNameMap[pathname]; - if (!name && idx >= 2) { - switch (pathnames[1]) { - case "eventStory": - if (events && idx === 2) { - const found = events.find( - (ev) => ev.id === Number(pathname) - ); - if (found) { - name = getTranslated( - `event_name:${pathname}`, - found.name - ); - } - } - if (eventStories && idx === 3) { - const found = eventStories.find( - (es) => es.eventId === Number(pathnames[2]) - ); - if (found) { - const episode = found.eventStoryEpisodes.find( - (ese) => ese.episodeNo === Number(pathname) - ); - if (episode) { - name = getTranslated( - `event_story_episode_title:${episode.eventStoryId}-${episode.episodeNo}`, - episode.title - ); - } - } - } - break; - case "unitStory": - if (unitProfiles && idx === 2) { - const found = unitProfiles.find( - (unit) => unit.unit === pathname - ); - if (found) { - name = getTranslated( - `unit_profile:${found.unit}.name`, - found.unitName - ); - } - } - if (unitStories) { - const found = unitStories.find( - (us) => us.unit === pathnames[2] - ); - if (found && idx === 3) { - const chapter = found.chapters.find( - (cp) => cp.chapterNo === Number(pathname) - ); - if (chapter) { - name = getTranslated( - `unit_story_chapter_title:${chapter.unit}-${chapter.chapterNo}`, - chapter.title - ); - } - } - if (found && idx === 4) { - const chapter = found.chapters.find( - (cp) => cp.chapterNo === Number(pathnames[3]) - ); - if (chapter) { - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(pathname) - ); - if (episode) { - name = getTranslated( - `unit_story_episode_title:${episode.unit}-${episode.chapterNo}-${episode.episodeNo}`, - episode.title - ); - } - } - } - } - break; - case "charaStory": - if (characterProfiles && idx === 2) { - const found = characterProfiles.find( - (cp) => cp.characterId === Number(pathname) - ); - if (found) { - name = getCharaName(found.characterId) || ""; - } - } - break; - case "cardStory": - if (characterProfiles && idx === 2) { - const found = characterProfiles.find( - (cp) => cp.characterId === Number(pathname) - ); - if (found) { - name = getCharaName(found.characterId) || ""; - } - } - if (cards && idx === 3) { - const card = cards.find( - (card) => card.id === Number(pathname) - ); - if (card) { - name = getTranslated( - `card_prefix:${card.id}`, - card.prefix - ); - } - } - if (cardEpisodes && idx === 4) { - const episode = cardEpisodes.find( - (cep) => cep.id === Number(pathname) - ); - if (episode) { - name = getTranslated( - `card_episode_title:${episode.title}`, - episode.title - ); - } - } - break; - case "areaTalk": - if (areas && idx === 2) { - const area = areas.find( - (area) => area.id === Number(pathname) - ); - if (area) { - name = getTranslated( - `area_name:${area.id}`, - area.name - ); - } - } - if (idx === 3) { - name = pathname; - } - break; - case "specialStory": - if (specialStories) { - if (idx === 2) { - const chapter = specialStories.find( - (sp) => sp.id === Number(pathname) - ); - if (chapter) { - name = chapter.title; - } - } else if (idx === 3) { - const chapter = specialStories.find( - (sp) => sp.id === Number(pathnames[2]) - ); - if (chapter) { - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(pathname) - ); - if (episode) { - name = episode.title; - } - } - } - } - } - } - - return last ? ( - - {name} - - ) : ( - - {name} - - ); - })} - - ); - }} - -
- - - - - - - - - {t("story_reader:selectValue.eventStory")} - - - - - - - - - - - {t("story_reader:selectValue.unitStory")} - - - - - - - - - - - {t("story_reader:selectValue.charaStory")} - - - - - - - - - - - {t("story_reader:selectValue.cardStory")} - - - - - - - - - - - {t("story_reader:selectValue.areaTalk")} - - - - - - - - - - - {t("story_reader:selectValue.special")} - - - - - - - - - - {t("story_reader:selectValue.liveTalk")} - - - - - - - - - {!!events && - (isShowSpoiler - ? events - : events.filter((e) => e.startAt <= new Date().getTime()) - ) - .slice() - .reverse() - .map((ev) => ( - - - - - - - - - - - - - ))} - - - - {({ match }) => { - const eventId = match?.params.eventId; - if (eventId && eventStories) { - const chapter = eventStories.find( - (es) => es.eventId === Number(eventId) - ); - if (chapter) { - return ( - - {!!chapter.outline && ( - - - {chapter.outline} - - - )} - {chapter.eventStoryEpisodes.map((episode) => ( - - - - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!unitProfiles && - unitProfiles.map((unit) => ( - - - - - - - - - - - - - ))} - - - - {({ match }) => { - // console.log(match); - const unit = match?.params.unit; - if (unit && unitStories) { - const stories = unitStories.find((us) => us.unit === unit); - if (stories) { - return ( - - {stories.chapters.map((chapter) => ( - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => { - // console.log(match); - const unit = match?.params.unit; - const chapterNo = match?.params.chapterNo; - if (unit && chapterNo && unitStories) { - const stories = unitStories.find((us) => us.unit === unit); - if (stories) { - const chapter = stories.chapters.find( - (s) => s.chapterNo === Number(chapterNo) - ); - if (chapter) { - return ( - - {chapter.episodes.map((episode) => ( - - - - - - - - - - - - - ))} - - ); - } - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!characterProfiles && - characterProfiles.map((character) => ( - - - - - - - - - - - - - - - - - ))} - - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!characterProfiles && - characterProfiles.map((character) => ( - - - - - - - - - - - - - - - - - ))} - - - - {({ match }) => { - const charaId = match?.params.charaId; - if (charaId && cards) { - const filteredCards = cards.filter( - (card) => - card.characterId === Number(charaId) && - (isShowSpoiler || - (card.releaseAt ?? card.archivePublishedAt!) <= - new Date().getTime()) - ); - if (filteredCards.length) { - return ( - - {filteredCards.map((card) => ( - - - - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => { - // console.log(match); - const cardId = match?.params.cardId; - if (cardId && cardEpisodes) { - const episodes = cardEpisodes.filter( - (ce) => ce.cardId === Number(cardId) - ); - if (episodes.length) { - return ( - - {episodes.map((episode) => ( - - - - - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!areas && - areas - .filter((area) => area.label) - .map((area) => ( - - - - - - - - - - - - - ))} - {!!areas && - areas - .filter( - (area) => area.areaType === "spirit_world" && !area.label - ) - .map((area) => ( - - - - - - - - - - - - - ))} - {!!areas && - areas - .filter((area) => area.areaType === "reality_world") - .map((area, idx) => ( - - - - - - - - - - - - - ))} - - - - {({ match }) => { - const areaId = match?.params.areaId; - if (areaId && areas) { - const area = areas.find((area) => area.id === Number(areaId)); - if (area && actionSets && chara2Ds) { - return ( - - {actionSets - .filter((as) => as.areaId === Number(areaId)) - .map((actionSet) => ( - - - - - - {actionSet.characterIds.map((charaId) => { - const characterId = chara2Ds.find( - (c2d) => c2d.id === charaId - )!.characterId; - return ( - - - - ); - })} - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - - - {!!specialStories && - specialStories - .slice() - .reverse() - .map((sp) => ( - - - - - {/* */} - - {sp.title} - - - - - - ))} - - - - {({ match }) => { - const storyId = match?.params.storyId; - if (storyId && specialStories) { - const chapter = specialStories.find( - (sp) => sp.id === Number(storyId) - ); - if (chapter) { - return ( - - {chapter.episodes.map((episode) => ( - - - - - {/* */} - - {episode.title} - - - - - - ))} - - ); - } - } - }} - - - {({ match }) => - !!match && ( - - ) - } - - -
+ <> +
+ {t("common:storyReader")} +
+ + {story && ( + + )} + ); -}); +}; -export default StoryReader; +export default StoryReaderLive2D; From 2e9031105efed9c419ec7e9059f4455a2cc12d46 Mon Sep 17 00:00:00 2001 From: K-bai Date: Wed, 22 Jan 2025 17:09:16 +0800 Subject: [PATCH 02/10] refactor(storyreader): create story loading module for text and live2d reader --- src/components/story-selector/Path.tsx | 236 ++++++ .../story-selector/StorySelector.tsx | 231 +----- .../StoryReaderLive2DContent.tsx | 63 +- src/pages/storyreader/StoryReaderContent.tsx | 255 +------ src/utils/Live2DPlayer/README.md | 7 +- src/utils/Live2DPlayer/layer/Dialog.ts | 1 + src/utils/Live2DPlayer/load.ts | 319 +------- src/utils/storyLoader.ts | 710 ++++++++++++++++++ 8 files changed, 1039 insertions(+), 783 deletions(-) create mode 100644 src/components/story-selector/Path.tsx create mode 100644 src/utils/storyLoader.ts diff --git a/src/components/story-selector/Path.tsx b/src/components/story-selector/Path.tsx new file mode 100644 index 00000000..f16c2437 --- /dev/null +++ b/src/components/story-selector/Path.tsx @@ -0,0 +1,236 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Route } from "react-router-dom"; +import { useCachedData } from "../../utils"; +import { useAssetI18n, useCharaName } from "../../utils/i18n"; + +import type { + IUnitProfile, + IUnitStory, + IEventStory, + IEventInfo, + ICharaProfile, + ICardEpisode, + ICardInfo, + IArea, + ISpecialStory, +} from "../../types.d"; + +import { Breadcrumbs, Typography } from "@mui/material"; + +import LinkNoDecoration from "../../components/styled/LinkNoDecoration"; + +const Path: React.FC<{ + catagory: { + [key: string]: { + breadcrumbName: string; + path: string; + disabled: boolean; + }; + }; +}> = ({ catagory }) => { + const { t } = useTranslation(); + const { getTranslated } = useAssetI18n(); + const getCharaName = useCharaName(); + const [unitProfiles] = useCachedData("unitProfiles"); + const [unitStories] = useCachedData("unitStories"); + const [eventStories] = useCachedData("eventStories"); + const [events] = useCachedData("events"); + const [characterProfiles] = useCachedData("characterProfiles"); + const [cardEpisodes] = useCachedData("cardEpisodes"); + const [cards] = useCachedData("cards"); + const [areas] = useCachedData("areas"); + const [specialStories] = useCachedData("specialStories"); + + return ( + + {({ location }) => { + const pathnames = location.pathname.split("/").filter((x) => x); + return ( + + {pathnames.map((pathname, idx) => { + const last = idx === pathnames.length - 1; + const to = `/${pathnames.slice(0, idx + 1).join("/")}`; + + let name = ""; + if (idx === 0) { + name = t("common:storyReader"); + } else if ( + idx === 1 && + Object.keys(catagory).includes(pathname) + ) { + name = catagory[pathname].breadcrumbName; + } else if (idx >= 2) { + switch (pathnames[1]) { + case "eventStory": + if (events && idx === 2) { + const found = events.find( + (ev) => ev.id === Number(pathname) + ); + if (found) { + name = getTranslated( + `event_name:${pathname}`, + found.name + ); + } + } + if (eventStories && idx === 3) { + const found = eventStories.find( + (es) => es.eventId === Number(pathnames[2]) + ); + if (found) { + const episode = found.eventStoryEpisodes.find( + (ese) => ese.episodeNo === Number(pathname) + ); + if (episode) { + name = getTranslated( + `event_story_episode_title:${episode.eventStoryId}-${episode.episodeNo}`, + episode.title + ); + } + } + } + break; + case "unitStory": + if (unitProfiles && idx === 2) { + const found = unitProfiles.find( + (unit) => unit.unit === pathname + ); + if (found) { + name = getTranslated( + `unit_profile:${found.unit}.name`, + found.unitName + ); + } + } + if (unitStories) { + const found = unitStories.find( + (us) => us.unit === pathnames[2] + ); + if (found && idx === 3) { + const chapter = found.chapters.find( + (cp) => cp.chapterNo === Number(pathname) + ); + if (chapter) { + name = getTranslated( + `unit_story_chapter_title:${chapter.unit}-${chapter.chapterNo}`, + chapter.title + ); + } + } + if (found && idx === 4) { + const chapter = found.chapters.find( + (cp) => cp.chapterNo === Number(pathnames[3]) + ); + if (chapter) { + const episode = chapter.episodes.find( + (ep) => ep.episodeNo === Number(pathname) + ); + if (episode) { + name = getTranslated( + `unit_story_episode_title:${episode.unit}-${episode.chapterNo}-${episode.episodeNo}`, + episode.title + ); + } + } + } + } + break; + case "charaStory": + if (characterProfiles && idx === 2) { + const found = characterProfiles.find( + (cp) => cp.characterId === Number(pathname) + ); + if (found) { + name = getCharaName(found.characterId) || ""; + } + } + break; + case "cardStory": + if (characterProfiles && idx === 2) { + const found = characterProfiles.find( + (cp) => cp.characterId === Number(pathname) + ); + if (found) { + name = getCharaName(found.characterId) || ""; + } + } + if (cards && idx === 3) { + const card = cards.find( + (card) => card.id === Number(pathname) + ); + if (card) { + name = getTranslated( + `card_prefix:${card.id}`, + card.prefix + ); + } + } + if (cardEpisodes && idx === 4) { + const episode = cardEpisodes.find( + (cep) => cep.id === Number(pathname) + ); + if (episode) { + name = getTranslated( + `card_episode_title:${episode.title}`, + episode.title + ); + } + } + break; + case "areaTalk": + if (areas && idx === 2) { + const area = areas.find( + (area) => area.id === Number(pathname) + ); + if (area) { + name = getTranslated(`area_name:${area.id}`, area.name); + } + } + if (idx === 3) { + name = pathname; + } + break; + case "specialStory": + if (specialStories) { + if (idx === 2) { + const chapter = specialStories.find( + (sp) => sp.id === Number(pathname) + ); + if (chapter) { + name = chapter.title; + } + } else if (idx === 3) { + const chapter = specialStories.find( + (sp) => sp.id === Number(pathnames[2]) + ); + if (chapter) { + const episode = chapter.episodes.find( + (ep) => ep.episodeNo === Number(pathname) + ); + if (episode) { + name = episode.title; + } + } + } + } + } + } + + return last ? ( + + {name} + + ) : ( + + {name} + + ); + })} + + ); + }} + + ); +}; +export default Path; diff --git a/src/components/story-selector/StorySelector.tsx b/src/components/story-selector/StorySelector.tsx index a0db4249..b8061670 100644 --- a/src/components/story-selector/StorySelector.tsx +++ b/src/components/story-selector/StorySelector.tsx @@ -2,30 +2,9 @@ import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Route, Switch, useRouteMatch } from "react-router-dom"; -import type { - ServerRegion, - IUnitProfile, - IUnitStory, - IEventStory, - IEventInfo, - ICharaProfile, - ICardEpisode, - ICardInfo, - IArea, - ISpecialStory, -} from "../../types.d"; +import type { ServerRegion } from "../../types.d"; -import { useCachedData } from "../../utils"; -import { useAssetI18n, useCharaName } from "../../utils/i18n"; -import { - Breadcrumbs, - Grid, - Typography, - Card, - CardContent, - styled, -} from "@mui/material"; -import LinkNoDecoration from "../../components/styled/LinkNoDecoration"; +import { Grid, Typography, Card, CardContent, styled } from "@mui/material"; import LinkNoDecorationAlsoNoHover from "../../components/styled/LinkNoDecorationAlsoHover"; const CardSelect = styled(Card)` &:hover { @@ -34,6 +13,7 @@ const CardSelect = styled(Card)` } `; +import Path from "./Path"; import EventStory from "./EventStory"; import UnitStory from "./UnitStory"; import CharaStory from "./CharaStory"; @@ -49,20 +29,8 @@ const StorySelector: React.FC<{ }) => void; }> = ({ onSetStory }) => { const { t } = useTranslation(); - const { getTranslated } = useAssetI18n(); - const getCharaName = useCharaName(); const { path } = useRouteMatch(); - const [unitProfiles] = useCachedData("unitProfiles"); - const [unitStories] = useCachedData("unitStories"); - const [eventStories] = useCachedData("eventStories"); - const [events] = useCachedData("events"); - const [characterProfiles] = useCachedData("characterProfiles"); - const [cardEpisodes] = useCachedData("cardEpisodes"); - const [cards] = useCachedData("cards"); - const [areas] = useCachedData("areas"); - const [specialStories] = useCachedData("specialStories"); - const handleSetStory = onSetStory; const catagory: { @@ -114,198 +82,7 @@ const StorySelector: React.FC<{ return ( <> - - {({ location }) => { - const pathnames = location.pathname.split("/").filter((x) => x); - console.log(pathnames); - return ( - - {pathnames.map((pathname, idx) => { - const last = idx === pathnames.length - 1; - const to = `/${pathnames.slice(0, idx + 1).join("/")}`; - - let name = ""; - if (idx === 0) { - name = t("common:storyReader"); - } else if ( - idx === 1 && - Object.keys(catagory).includes(pathname) - ) { - name = catagory[pathname].breadcrumbName; - } else if (idx >= 2) { - switch (pathnames[1]) { - case "eventStory": - if (events && idx === 2) { - const found = events.find( - (ev) => ev.id === Number(pathname) - ); - if (found) { - name = getTranslated( - `event_name:${pathname}`, - found.name - ); - } - } - if (eventStories && idx === 3) { - const found = eventStories.find( - (es) => es.eventId === Number(pathnames[2]) - ); - if (found) { - const episode = found.eventStoryEpisodes.find( - (ese) => ese.episodeNo === Number(pathname) - ); - if (episode) { - name = getTranslated( - `event_story_episode_title:${episode.eventStoryId}-${episode.episodeNo}`, - episode.title - ); - } - } - } - break; - case "unitStory": - if (unitProfiles && idx === 2) { - const found = unitProfiles.find( - (unit) => unit.unit === pathname - ); - if (found) { - name = getTranslated( - `unit_profile:${found.unit}.name`, - found.unitName - ); - } - } - if (unitStories) { - const found = unitStories.find( - (us) => us.unit === pathnames[2] - ); - if (found && idx === 3) { - const chapter = found.chapters.find( - (cp) => cp.chapterNo === Number(pathname) - ); - if (chapter) { - name = getTranslated( - `unit_story_chapter_title:${chapter.unit}-${chapter.chapterNo}`, - chapter.title - ); - } - } - if (found && idx === 4) { - const chapter = found.chapters.find( - (cp) => cp.chapterNo === Number(pathnames[3]) - ); - if (chapter) { - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(pathname) - ); - if (episode) { - name = getTranslated( - `unit_story_episode_title:${episode.unit}-${episode.chapterNo}-${episode.episodeNo}`, - episode.title - ); - } - } - } - } - break; - case "charaStory": - if (characterProfiles && idx === 2) { - const found = characterProfiles.find( - (cp) => cp.characterId === Number(pathname) - ); - if (found) { - name = getCharaName(found.characterId) || ""; - } - } - break; - case "cardStory": - if (characterProfiles && idx === 2) { - const found = characterProfiles.find( - (cp) => cp.characterId === Number(pathname) - ); - if (found) { - name = getCharaName(found.characterId) || ""; - } - } - if (cards && idx === 3) { - const card = cards.find( - (card) => card.id === Number(pathname) - ); - if (card) { - name = getTranslated( - `card_prefix:${card.id}`, - card.prefix - ); - } - } - if (cardEpisodes && idx === 4) { - const episode = cardEpisodes.find( - (cep) => cep.id === Number(pathname) - ); - if (episode) { - name = getTranslated( - `card_episode_title:${episode.title}`, - episode.title - ); - } - } - break; - case "areaTalk": - if (areas && idx === 2) { - const area = areas.find( - (area) => area.id === Number(pathname) - ); - if (area) { - name = getTranslated( - `area_name:${area.id}`, - area.name - ); - } - } - if (idx === 3) { - name = pathname; - } - break; - case "specialStory": - if (specialStories) { - if (idx === 2) { - const chapter = specialStories.find( - (sp) => sp.id === Number(pathname) - ); - if (chapter) { - name = chapter.title; - } - } else if (idx === 3) { - const chapter = specialStories.find( - (sp) => sp.id === Number(pathnames[2]) - ); - if (chapter) { - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(pathname) - ); - if (episode) { - name = episode.title; - } - } - } - } - } - } - - return last ? ( - - {name} - - ) : ( - - {name} - - ); - })} - - ); - }} - + diff --git a/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx b/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx index 184d943a..5edba078 100644 --- a/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx +++ b/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx @@ -1,11 +1,14 @@ import React, { useRef, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { - useLive2DScenarioUrl, - getProcessedLive2DScenarioData, getLive2DControllerData, preloadModels, } from "../../utils/Live2DPlayer/load"; +import { + useScenarioInfo, + getProcessedScenarioDataForLive2D, + useMediaUrlForLive2D, +} from "../../utils/storyLoader"; import { ILive2DControllerData, IProgressEvent, @@ -23,6 +26,7 @@ import { Checkbox, } from "@mui/material"; import StoryReaderLive2DCanvas from "./StoryReaderLive2DCanvas"; +import { useAlertSnackbar } from "../../utils"; const StoryReaderLive2DContent: React.FC<{ storyType: string; @@ -30,7 +34,8 @@ const StoryReaderLive2DContent: React.FC<{ region: ServerRegion; }> = ({ storyType, storyId, region }) => { const { t } = useTranslation(); - const getLive2DScenarioUrl = useLive2DScenarioUrl(); + const getScenarioInfo = useScenarioInfo(); + const getMediaUrlForLive2D = useMediaUrlForLive2D(); const scenarioData = useRef(); const controllerData = useRef(); @@ -39,6 +44,8 @@ const StoryReaderLive2DContent: React.FC<{ const [progressText, setProgressText] = useState(""); const [autoplay, setAutoplay] = useState(false); + const { showError } = useAlertSnackbar(); + const canvas = useRef(null); const loadButtonText = useMemo(() => { @@ -89,28 +96,50 @@ const StoryReaderLive2DContent: React.FC<{ setLoadStatus(LoadStatus.Loading); // step 1 - get scenario url setProgressText(t("story_reader_live2d:progress.get_resource_url")); - const scenarioUrl = await getLive2DScenarioUrl(storyType, storyId, region); + let scenarioInfo; + try { + scenarioInfo = await getScenarioInfo(storyType, storyId, region); + } catch (err) { + if (err instanceof Error) showError(err.message); + setLoadStatus(LoadStatus.Ready); + return; + } setLoadProgress(1); - if (scenarioUrl) { + if (scenarioInfo) { // // step 2 - get scenario data setProgressText(t("story_reader_live2d:progress.get_scenario_data")); - scenarioData.current = await getProcessedLive2DScenarioData( - scenarioUrl.url, + scenarioData.current = await getProcessedScenarioDataForLive2D( + scenarioInfo, region ); setLoadProgress(2); // step 3 - get controller data (preload media) - const ctData = await getLive2DControllerData( - scenarioData.current, - scenarioUrl.isCardStory, - scenarioUrl.isActionSet, - handleProgress - ); - // step 4 - preload model - await preloadModels(ctData, handleProgress); - controllerData.current = ctData; + // step 3.1 - load media url + let mediaUrl; + try { + mediaUrl = await getMediaUrlForLive2D( + scenarioInfo, + scenarioData.current, + region + ); + } catch (err) { + if (err instanceof Error) showError(err.message); + setLoadStatus(LoadStatus.Ready); + return; + } + if (mediaUrl) { + // step 3.2 preload media + const ctData = await getLive2DControllerData( + scenarioData.current, + mediaUrl, + handleProgress + ); + // step 4 - preload model + await preloadModels(ctData, handleProgress); + controllerData.current = ctData; + setLoadStatus(LoadStatus.Loaded); + } } - setLoadStatus(LoadStatus.Loaded); } function fullscreen() { diff --git a/src/pages/storyreader/StoryReaderContent.tsx b/src/pages/storyreader/StoryReaderContent.tsx index 2b4b2f33..bcc3e05f 100644 --- a/src/pages/storyreader/StoryReaderContent.tsx +++ b/src/pages/storyreader/StoryReaderContent.tsx @@ -1,29 +1,16 @@ import { Chip, Grid, LinearProgress, Paper, Typography } from "@mui/material"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { SnippetAction, ServerRegion } from "../../types.d"; import { - IUnitStory, - IEventStory, - SnippetAction, - ICharaProfile, - ICardEpisode, - IActionSet, - ISpecialStory, - ICardInfo, - ServerRegion, -} from "../../types.d"; -import { - getRemoteAssetURL, - useAlertSnackbar, - useCachedData, - useProcessedScenarioData, -} from "../../utils"; -import { charaIcons } from "../../utils/resources"; + useScenarioInfo, + useProcessedScenarioDataForText, +} from "../../utils/storyLoader"; import { ReleaseCondTrans } from "../../components/helpers/ContentTrans"; import { Sound, SpecialEffect, Talk } from "./StoryReaderSnippet"; import Image from "mui-image"; -import { useAssetI18n } from "../../utils/i18n"; import ContainerContent from "../../components/styled/ContainerContent"; +import { useAlertSnackbar } from "../../utils"; const StoryReaderContent: React.FC<{ storyType: string; @@ -31,18 +18,10 @@ const StoryReaderContent: React.FC<{ region: ServerRegion; }> = ({ storyType, storyId, region }) => { const { t } = useTranslation(); - const { getTranslated } = useAssetI18n(); - const getProcessedScenarioData = useProcessedScenarioData(); + const getScenarioInfo = useScenarioInfo(); + const getProcessedScenarioDataForText = useProcessedScenarioDataForText(); const { showError } = useAlertSnackbar(); - const [cards] = useCachedData("cards"); - const [unitStories] = useCachedData("unitStories"); - const [eventStories] = useCachedData("eventStories"); - const [characterProfiles] = useCachedData("characterProfiles"); - const [cardEpisodes] = useCachedData("cardEpisodes"); - const [actionSets] = useCachedData("actionSets"); - const [specialStories] = useCachedData("specialStories"); - const [bannerUrl, setBannerUrl] = useState(""); const [chapterTitle, setChapterTitle] = useState(""); const [episodeTitle, setEpisodeTitle] = useState(""); @@ -65,208 +44,30 @@ const StoryReaderContent: React.FC<{ actions: [], characters: [], }); - try { - switch (storyType) { - case "unitStory": - if (unitStories) { - const [, , , unitId, chapterNo, episodeNo] = storyId.split("/"); - - const chapter = unitStories - .find((us) => us.unit === unitId)! - .chapters.find((ch) => ch.chapterNo === Number(chapterNo))!; - - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(episodeNo) - )!; - - getRemoteAssetURL( - `story/episode_image/${chapter.assetbundleName}_rip/${episode.assetbundleName}.webp`, - setBannerUrl, - "minio" - ); - getProcessedScenarioData( - `scenario/unitstory/${chapter.assetbundleName}_rip/${episode.scenarioId}.asset`, - false - ).then((data) => setScenarioData(data)); - - setChapterTitle( - getTranslated( - `unit_story_chapter_title:${chapter.unit}-${chapter.chapterNo}`, - chapter.title - ) - ); - setEpisodeTitle( - getTranslated( - `unit_story_episode_title:${episode.unit}-${episode.chapterNo}-${episode.episodeNo}`, - episode.title - ) - ); - setReleaseConditionId(episode.releaseConditionId); - } - break; - case "eventStory": - if (eventStories) { - const [, , , eventId, episodeNo] = storyId.split("/"); - - const chapter = eventStories.find( - (es) => es.eventId === Number(eventId) - )!; - - const episode = chapter.eventStoryEpisodes.find( - (ep) => ep.episodeNo === Number(episodeNo) - )!; - - getRemoteAssetURL( - `event_story/${chapter.assetbundleName}/episode_image_rip/${episode.assetbundleName}.webp`, - setBannerUrl, - "minio" - ); - getProcessedScenarioData( - `event_story/${chapter.assetbundleName}/scenario_rip/${episode.scenarioId}.asset`, - false - ).then((data) => setScenarioData(data)); - - setChapterTitle(""); - setEpisodeTitle( - `${episode.episodeNo} - ${getTranslated( - `event_story_episode_title:${episode.eventStoryId}-${episode.episodeNo}`, - episode.title - )}` - ); - setReleaseConditionId(episode.releaseConditionId); - } - break; - case "charaStory": - if (characterProfiles) { - const [, , , charaId] = storyId.split("/"); - - const episode = characterProfiles.find( - (cp) => cp.characterId === Number(charaId) - )!; - - setBannerUrl(charaIcons[`CharaIcon${charaId}` as "CharaIcon1"]); - getProcessedScenarioData( - `scenario/profile_rip/${episode.scenarioId}.asset`, - false - ).then((data) => setScenarioData(data)); - - setChapterTitle(""); - setEpisodeTitle(t("member:introduction")); - setReleaseConditionId(0); - } - break; - case "cardStory": - if (cardEpisodes) { - const [, , , , , cardEpisodeId] = storyId.split("/"); - - const episode = cardEpisodes.find( - (ce) => ce.id === Number(cardEpisodeId) - )!; - let assetbundleName = episode.assetbundleName; - if (!assetbundleName && !!cards) { - const card = cards.find((card) => card.id === episode.cardId); - if (card) { - assetbundleName = card.assetbundleName; - } - } - - if (assetbundleName) { - // setBannerUrl(charaIcons[`CharaIcon${charaId}` as "CharaIcon1"]); - getRemoteAssetURL( - `character/member_small/${assetbundleName}_rip/card_normal.webp`, - setBannerUrl, - "minio" - ); - if (region === "en") - getProcessedScenarioData( - `character/member_scenario/${assetbundleName}_rip/${episode.scenarioId}.asset`, - true - ).then((data) => setScenarioData(data)); - else - getProcessedScenarioData( - `character/member/${assetbundleName}_rip/${episode.scenarioId}.asset`, - true - ).then((data) => setScenarioData(data)); - - setChapterTitle(""); - setEpisodeTitle( - getTranslated( - `card_episode_title:${episode.title}`, - episode.title - ) - ); - setReleaseConditionId(episode.releaseConditionId); - } - } - break; - case "areaTalk": - if (actionSets) { - const [, , , , actionSetId] = storyId.split("/"); - - const episode = actionSets.find( - (as) => as.id === Number(actionSetId) - )!; - - // getRemoteAssetURL( - // `character/member_small/${episode.assetbundleName}_rip/card_normal.webp`, - // setBannerUrl, - // window.isChinaMainland - // ); - getProcessedScenarioData( - `scenario/actionset/group${Math.floor(episode.id / 100)}_rip/${ - episode.scenarioId - }.asset`, - false, - true - ).then((data) => setScenarioData(data)); - - setChapterTitle(""); - setEpisodeTitle(""); - // setReleaseConditionId(episode.releaseConditionId); - } - break; - case "specialStory": - if (specialStories) { - const [, , , spId, episodeNo] = storyId.split("/"); - const chapter = specialStories.find((sp) => sp.id === Number(spId)); - const episode = chapter?.episodes.find( - (ep) => ep.episodeNo === Number(episodeNo) - ); - - if (episode?.scenarioId.startsWith("op")) - getProcessedScenarioData( - `scenario/special/${chapter?.assetbundleName}_rip/${episode?.scenarioId}.asset`, - false - ).then((data) => setScenarioData(data)); - else - getProcessedScenarioData( - `scenario/special/${episode?.assetbundleName}_rip/${episode?.scenarioId}.asset`, - false - ).then((data) => setScenarioData(data)); - - setChapterTitle(chapter?.title || ""); - setEpisodeTitle(episode?.title || ""); - } - break; - } - } catch (error) { - showError("failed to load episode"); - } + console.log("load"); + getScenarioInfo(storyType, storyId, region) + .then((info) => { + if (info) { + if (info.bannerUrl) setBannerUrl(info.bannerUrl); + if (info.chapterTitle) setChapterTitle(info.chapterTitle); + if (info.episodeTitle) setEpisodeTitle(info.episodeTitle); + if (info.releaseConditionId) + setReleaseConditionId(info.releaseConditionId); + return getProcessedScenarioDataForText(info, region); + } + }) + .then((data) => { + if (data) setScenarioData(data); + }) + .catch((err) => { + if (err instanceof Error) showError(err.message); + }); }, [ - unitStories, - eventStories, - storyId, storyType, - getProcessedScenarioData, - characterProfiles, - cardEpisodes, - t, - getTranslated, - showError, - actionSets, - specialStories, - cards, + storyId, region, + getScenarioInfo, + getProcessedScenarioDataForText, ]); return ( diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index 4c459063..79c187ee 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -72,7 +72,12 @@ - refactor(live2d): move every layer into seperate classes - commit 3e804848f9f3a1a8eaa41d9bac9d8e84adf635d1 - refactor(live2d): animation classes -- commit this +- commit 7274ffadd2ee25abc3da452f080b263ed68467ec - feat(live2d): implement shake & ambient color & wipe & sekai in/out effect - perf(live2d): load ui assets on necessary - fix(live2d): loading progress sometimes wrong +- commit 0efc83881d1fa1a9860ed165ff5f5a36666b0649 + - fix(live2d): cross origin policy are not set for images and sounds +- commit this + - refactor(storyreader): create story loading module for text and live2d reader + - fix(live2d): smaller dialog line height \ No newline at end of file diff --git a/src/utils/Live2DPlayer/layer/Dialog.ts b/src/utils/Live2DPlayer/layer/Dialog.ts index fd4da5cd..713e0c2b 100644 --- a/src/utils/Live2DPlayer/layer/Dialog.ts +++ b/src/utils/Live2DPlayer/layer/Dialog.ts @@ -98,6 +98,7 @@ export default class Dialog extends BaseLayer { text.style = new TextStyle({ fill: ["#ffffff"], fontSize: this.em(16), + lineHeight: this.em(22), breakWords: true, wordWrap: true, wordWrapWidth: this.stage_size[0] * 0.7, diff --git a/src/utils/Live2DPlayer/load.ts b/src/utils/Live2DPlayer/load.ts index e2a20a62..4f04ca1e 100644 --- a/src/utils/Live2DPlayer/load.ts +++ b/src/utils/Live2DPlayer/load.ts @@ -1,35 +1,10 @@ import Axios from "axios"; import { Howl } from "howler"; import { assetUrl } from "../urls"; -import { useCallback } from "react"; -import { getRemoteAssetURL, useCachedData } from ".."; -import { getUIMediaUrls } from "./ui_assets"; -import { - ServerRegion, - Snippet, - SnippetAction, - SnippetProgressBehavior, - SpecialEffectType, - SpecialEffectData, - SoundData, - SoundPlayMode, -} from "../../types.d"; -import type { - IScenarioData, - IUnitStory, - IEventStory, - ICharaProfile, - ICardEpisode, - ICardInfo, - IActionSet, - ISpecialStory, -} from "../../types.d"; +import { SnippetAction } from "../../types.d"; +import type { IScenarioData } from "../../types.d"; -import { - Live2DAssetType, - Live2DAssetTypeImage, - Live2DAssetTypeSound, -} from "./types.d"; +import { Live2DAssetTypeImage, Live2DAssetTypeSound } from "./types.d"; import type { ILive2DCachedAsset, ILive2DAssetUrl, @@ -39,211 +14,20 @@ import type { IProgressEvent, } from "./types.d"; -import { PreloadQueue } from "./PreloadQueue"; -import { log } from "./log"; - -// step 1 - get scenario url -export function useLive2DScenarioUrl() { - const [unitStories] = useCachedData("unitStories"); - const [eventStories] = useCachedData("eventStories"); - const [characterProfiles] = useCachedData("characterProfiles"); - const [cardEpisodes] = useCachedData("cardEpisodes"); - const [cards] = useCachedData("cards"); - const [actionSets] = useCachedData("actionSets"); - const [specialStories] = useCachedData("specialStories"); - - return useCallback( - async (storyType: string, storyId: string, region: ServerRegion) => { - switch (storyType) { - case "unitStory": - if (unitStories) { - const [, , , unitId, chapterNo, episodeNo] = storyId.split("/"); - - const chapter = unitStories - .find((us) => us.unit === unitId)! - .chapters.find((ch) => ch.chapterNo === Number(chapterNo))!; - - const episode = chapter.episodes.find( - (ep) => ep.episodeNo === Number(episodeNo) - )!; - return { - url: `scenario/unitstory/${chapter.assetbundleName}_rip/${episode.scenarioId}.asset`, - isCardStory: false, - isActionSet: false, - }; - } - break; - case "eventStory": - if (eventStories) { - const [, , , eventId, episodeNo] = storyId.split("/"); - - const chapter = eventStories.find( - (es) => es.eventId === Number(eventId) - )!; - - const episode = chapter.eventStoryEpisodes.find( - (ep) => ep.episodeNo === Number(episodeNo) - )!; - return { - url: `event_story/${chapter.assetbundleName}/scenario_rip/${episode.scenarioId}.asset`, - isCardStory: false, - isActionSet: false, - }; - } - break; - case "charaStory": - if (characterProfiles) { - const [, , , charaId] = storyId.split("/"); - - const episode = characterProfiles.find( - (cp) => cp.characterId === Number(charaId) - )!; - return { - url: `scenario/profile_rip/${episode.scenarioId}.asset`, - isCardStory: false, - isActionSet: false, - }; - } - break; - case "cardStory": - if (cardEpisodes) { - const [, , , , , cardEpisodeId] = storyId.split("/"); - - const episode = cardEpisodes.find( - (ce) => ce.id === Number(cardEpisodeId) - )!; - let assetbundleName = episode.assetbundleName; - if (!assetbundleName && !!cards) { - const card = cards.find((card) => card.id === episode.cardId); - if (card) { - assetbundleName = card.assetbundleName; - } - } - - if (assetbundleName) { - if (region === "en") - return { - url: `character/member_scenario/${assetbundleName}_rip/${episode.scenarioId}.asset`, - isCardStory: true, - isActionSet: false, - }; - else - return { - url: `character/member/${assetbundleName}_rip/${episode.scenarioId}.asset`, - isCardStory: true, - isActionSet: false, - }; - } - } - break; - case "areaTalk": - if (actionSets) { - const [, , , , actionSetId] = storyId.split("/"); +import { getUIMediaUrls } from "./ui_assets"; - const episode = actionSets.find( - (as) => as.id === Number(actionSetId) - )!; - return { - url: `scenario/actionset/group${Math.floor(episode.id / 100)}_rip/${ - episode.scenarioId - }.asset`, - isCardStory: false, - isActionSet: true, - }; - } - break; - case "specialStory": - if (specialStories) { - const [, , , spId, episodeNo] = storyId.split("/"); - const chapter = specialStories.find((sp) => sp.id === Number(spId)); - const episode = chapter?.episodes.find( - (ep) => ep.episodeNo === Number(episodeNo) - ); - return { - url: `scenario/special/${chapter?.assetbundleName}_rip/${episode?.scenarioId}.asset`, - isCardStory: false, - isActionSet: false, - }; - } - break; - } - }, - [ - unitStories, - eventStories, - characterProfiles, - cardEpisodes, - actionSets, - specialStories, - cards, - ] - ); -} -// step 2 - get scenario data -export async function getProcessedLive2DScenarioData( - scenarioUrl: string, - region: ServerRegion -) { - const { data }: { data: IScenarioData } = await Axios.get( - await getRemoteAssetURL(scenarioUrl, undefined, "minio", region), - { - responseType: "json", - } - ); - log.log("Live2DLoader", data); - const { Snippets, SpecialEffectData, SoundData, FirstBgm, FirstBackground } = - data; +import { PreloadQueue } from "./PreloadQueue"; - if (FirstBackground) { - const bgSnippet: Snippet = { - Action: SnippetAction.SpecialEffect, - ProgressBehavior: SnippetProgressBehavior.Now, - ReferenceIndex: SpecialEffectData.length, - Delay: 0, - }; - const spData: SpecialEffectData = { - EffectType: SpecialEffectType.ChangeBackground, - StringVal: FirstBackground, - StringValSub: FirstBackground, - Duration: 0, - IntVal: 0, - }; - Snippets.unshift(bgSnippet); - SpecialEffectData.push(spData); - } - if (FirstBgm) { - const bgmSnippet: Snippet = { - Action: SnippetAction.Sound, - ProgressBehavior: SnippetProgressBehavior.Now, - ReferenceIndex: SoundData.length, - Delay: 0, - }; - const soundData: SoundData = { - PlayMode: SoundPlayMode.CrossFade, - Bgm: FirstBgm, - Se: "", - Volume: 1, - SeBundleName: "", - Duration: 2.5, - }; - Snippets.unshift(bgmSnippet); - SoundData.push(soundData); - } - return data; -} // step 3 - get controller data (preload media) export async function getLive2DControllerData( snData: IScenarioData, - isCardStory: boolean = false, - isActionSet: boolean = false, + mediaUrlForLive2D: ILive2DAssetUrl[], progress: IProgressEvent ): Promise { - // step 3.1 - get sound/image urls - const urls = await getMediaUrls(snData, isCardStory, isActionSet); // step 3.1.2 - get live2d player ui urls - urls.push(...getUIMediaUrls(snData)); + mediaUrlForLive2D.push(...getUIMediaUrls(snData)); // step 3.2 - preload sound/image - const scenarioResource = await preloadMedia(urls, progress); + const scenarioResource = await preloadMedia(mediaUrlForLive2D, progress); // step 3.3 - get live2d model data const modelData = []; const total = snData.AppearCharacters.length; @@ -311,93 +95,6 @@ export async function preloadModels( await preloadModelMotion(controllerData.modelData, progress); } -// step 3.1 - get sound/image urls -async function getMediaUrls( - snData: IScenarioData, - isCardStory: boolean = false, - isActionSet: boolean = false -): Promise { - const ret: ILive2DAssetUrl[] = []; - if (!snData) return ret; - const { ScenarioId, Snippets, TalkData, SpecialEffectData, SoundData } = - snData; - // get all urls - for (const snippet of Snippets) { - switch (snippet.Action) { - case SnippetAction.Talk: - { - const talkData = TalkData[snippet.ReferenceIndex]; - const url = talkData.Voices.map((v) => ({ - identifer: v.VoiceId, - type: Live2DAssetType.Talk, - url: `sound/${isCardStory ? "card_" : ""}${ - isActionSet ? "actionset" : "scenario" - }/voice/${ScenarioId}_rip/${v.VoiceId}.mp3`, - })) as ILive2DAssetUrl[]; - for (const s of url) - if (!ret.map((r) => r.url).includes(s.url)) ret.push(s); - } - break; - case SnippetAction.SpecialEffect: - { - const seData = SpecialEffectData[snippet.ReferenceIndex]; - switch (seData.EffectType) { - case SpecialEffectType.ChangeBackground: - { - const identifer = seData.StringValSub; - const url = `scenario/background/${seData.StringValSub}_rip/${seData.StringValSub}.webp`; - if (ret.map((r) => r.url).includes(url)) continue; - ret.push({ - identifer, - type: Live2DAssetType.BackgroundImage, - url, - }); - } - break; - } - } - break; - case SnippetAction.Sound: - { - const soundData = SoundData[snippet.ReferenceIndex]; - if (soundData.Bgm) { - const identifer = soundData.Bgm; - const url = `sound/scenario/bgm/${soundData.Bgm}_rip/${soundData.Bgm}.mp3`; - if (ret.map((r) => r.url).includes(url)) continue; - ret.push({ - identifer, - type: Live2DAssetType.BackgroundMusic, - url, - }); - } else if (soundData.Se) { - const identifer = soundData.Se; - const isEventSe = identifer.startsWith("se_event"); - const baseDir = isEventSe - ? `event_story/${identifer.split("_").slice(1, -1).join("_")}` - : "sound/scenario/se"; - const seBundleName = isEventSe - ? "scenario_se" - : identifer.endsWith("_b") - ? "se_pack00001_b" - : "se_pack00001"; - const url = `${baseDir}/${seBundleName}_rip/${identifer}.mp3`; - if (ret.map((r) => r.url).includes(url)) continue; - ret.push({ - identifer, - type: Live2DAssetType.SoundEffect, - url, - }); - } - } - break; - } - } - log.log("Live2DLoader", ret); - for (const r of ret) { - r.url = await getRemoteAssetURL(r.url, undefined, "minio"); - } - return ret; -} // step 3.2 - preload sound/image export async function preloadMedia( urls: ILive2DAssetUrl[], diff --git a/src/utils/storyLoader.ts b/src/utils/storyLoader.ts new file mode 100644 index 00000000..b8dfe8e2 --- /dev/null +++ b/src/utils/storyLoader.ts @@ -0,0 +1,710 @@ +import Axios from "axios"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useCachedData, getRemoteAssetURL } from "."; +import { + ICharacter2D, + IMobCharacter, + IScenarioData, + IUnitStory, + IEventStory, + ICharaProfile, + ICardEpisode, + ICardInfo, + IActionSet, + ISpecialStory, + SnippetAction, + SpecialEffectType, + SnippetProgressBehavior, + SoundPlayMode, + ServerRegion, + TalkData, + Snippet, + SpecialEffectData, + SoundData, +} from "../types.d"; +import { ILive2DAssetUrl, Live2DAssetType } from "./Live2DPlayer/types.d"; +import { useCharaName, useAssetI18n } from "./i18n"; +import { charaIcons } from "./resources"; + +import { fixVoiceUrl } from "./voiceFinder"; + +interface IScenarioInfo { + bannerUrl?: string; + scenarioDataUrl: string; + isCardStory: boolean; + isActionSet: boolean; + chapterTitle?: string; + episodeTitle?: string; + releaseConditionId?: number; +} + +export function useScenarioInfo() { + const [unitStories] = useCachedData("unitStories"); + const [eventStories] = useCachedData("eventStories"); + const [characterProfiles] = useCachedData("characterProfiles"); + const [cardEpisodes] = useCachedData("cardEpisodes"); + const [cards] = useCachedData("cards"); + const [actionSets] = useCachedData("actionSets"); + const [specialStories] = useCachedData("specialStories"); + const { getTranslated } = useAssetI18n(); + const { t } = useTranslation(); + + return useCallback( + async ( + storyType: string, + storyId: string, + region: ServerRegion + ): Promise => { + try { + switch (storyType) { + case "unitStory": + if (unitStories) { + const [, , , unitId, chapterNo, episodeNo] = storyId.split("/"); + + const chapter = unitStories + .find((us) => us.unit === unitId)! + .chapters.find((ch) => ch.chapterNo === Number(chapterNo))!; + + const episode = chapter.episodes.find( + (ep) => ep.episodeNo === Number(episodeNo) + )!; + return { + bannerUrl: await getRemoteAssetURL( + `story/episode_image/${chapter.assetbundleName}_rip/${episode.assetbundleName}.webp`, + undefined, + "minio" + ), + scenarioDataUrl: `scenario/unitstory/${chapter.assetbundleName}_rip/${episode.scenarioId}.asset`, + isCardStory: false, + isActionSet: false, + chapterTitle: getTranslated( + `unit_story_chapter_title:${chapter.unit}-${chapter.chapterNo}`, + chapter.title + ), + episodeTitle: getTranslated( + `unit_story_episode_title:${episode.unit}-${episode.chapterNo}-${episode.episodeNo}`, + episode.title + ), + releaseConditionId: episode.releaseConditionId, + }; + } + break; + case "eventStory": + if (eventStories) { + const [, , , eventId, episodeNo] = storyId.split("/"); + + const chapter = eventStories.find( + (es) => es.eventId === Number(eventId) + )!; + + const episode = chapter.eventStoryEpisodes.find( + (ep) => ep.episodeNo === Number(episodeNo) + )!; + return { + bannerUrl: await getRemoteAssetURL( + `event_story/${chapter.assetbundleName}/episode_image_rip/${episode.assetbundleName}.webp`, + undefined, + "minio" + ), + scenarioDataUrl: `event_story/${chapter.assetbundleName}/scenario_rip/${episode.scenarioId}.asset`, + isCardStory: false, + isActionSet: false, + chapterTitle: "", + episodeTitle: `${episode.episodeNo} - ${getTranslated( + `event_story_episode_title:${episode.eventStoryId}-${episode.episodeNo}`, + episode.title + )}`, + releaseConditionId: episode.releaseConditionId, + }; + } + break; + case "charaStory": + if (characterProfiles) { + const [, , , charaId] = storyId.split("/"); + + const episode = characterProfiles.find( + (cp) => cp.characterId === Number(charaId) + )!; + + return { + bannerUrl: charaIcons[`CharaIcon${charaId}` as "CharaIcon1"], + scenarioDataUrl: `scenario/profile_rip/${episode.scenarioId}.asset`, + isCardStory: false, + isActionSet: false, + chapterTitle: "", + episodeTitle: t("member:introduction"), + releaseConditionId: 0, + }; + } + break; + case "cardStory": + if (cardEpisodes) { + const [, , , , , cardEpisodeId] = storyId.split("/"); + + const episode = cardEpisodes.find( + (ce) => ce.id === Number(cardEpisodeId) + )!; + let assetbundleName = episode.assetbundleName; + if (!assetbundleName && !!cards) { + const card = cards.find((card) => card.id === episode.cardId); + if (card) { + assetbundleName = card.assetbundleName; + } + } + + if (assetbundleName) { + return { + bannerUrl: `character/member_small/${assetbundleName}_rip/card_normal.webp`, + scenarioDataUrl: + region === "en" + ? `character/member_scenario/${assetbundleName}_rip/${episode.scenarioId}.asset` + : `character/member/${assetbundleName}_rip/${episode.scenarioId}.asset`, + isCardStory: true, + isActionSet: false, + chapterTitle: "", + episodeTitle: getTranslated( + `card_episode_title:${episode.title}`, + episode.title + ), + releaseConditionId: episode.releaseConditionId, + }; + } + } + break; + case "areaTalk": + if (actionSets) { + const [, , , , actionSetId] = storyId.split("/"); + + const episode = actionSets.find( + (as) => as.id === Number(actionSetId) + )!; + + return { + bannerUrl: undefined, + scenarioDataUrl: `scenario/actionset/group${Math.floor(episode.id / 100)}_rip/${ + episode.scenarioId + }.asset`, + isCardStory: false, + isActionSet: true, + chapterTitle: "", + episodeTitle: "", + releaseConditionId: undefined, + }; + } + break; + case "specialStory": + if (specialStories) { + const [, , , spId, episodeNo] = storyId.split("/"); + const chapter = specialStories.find( + (sp) => sp.id === Number(spId) + ); + const episode = chapter?.episodes.find( + (ep) => ep.episodeNo === Number(episodeNo) + ); + + return { + bannerUrl: undefined, + scenarioDataUrl: episode?.scenarioId.startsWith("op") + ? `scenario/special/${chapter?.assetbundleName}_rip/${episode?.scenarioId}.asset` + : `scenario/special/${episode?.assetbundleName}_rip/${episode?.scenarioId}.asset`, + isCardStory: false, + isActionSet: false, + chapterTitle: chapter?.title || "", + episodeTitle: episode?.title || "", + releaseConditionId: undefined, + }; + } + break; + } + } catch (error) { + throw new Error("failed to load episode"); + } + }, + [ + unitStories, + eventStories, + characterProfiles, + cardEpisodes, + t, + getTranslated, + actionSets, + specialStories, + cards, + ] + ); +} + +export function useProcessedScenarioDataForText() { + const [mobCharas] = useCachedData("mobCharacters"); + const [chara2Ds] = useCachedData("character2ds"); + + const getCharaName = useCharaName(); + + return useCallback( + async (info: IScenarioInfo, region: ServerRegion) => { + const ret: { + characters: { id: number; name: string }[]; + actions: { [key: string]: any }[]; + } = { + actions: [], + characters: [], + }; + + if (!chara2Ds || !chara2Ds.length || !info) return ret; + + const { data }: { data: IScenarioData } = await Axios.get( + await getRemoteAssetURL( + info.scenarioDataUrl, + undefined, + "minio", + region + ), + { + responseType: "json", + } + ); + const { + ScenarioId, + AppearCharacters, + Snippets, + TalkData, + // LayoutData, + SpecialEffectData, + SoundData, + FirstBgm, + FirstBackground, + } = data; + + const voiceMap: { + [key: string]: Record; + } = {}; + + if (FirstBackground) { + ret.actions.push({ + body: FirstBgm, + delay: 0, + isWait: SnippetProgressBehavior.WaitUnitilFinished, + resource: await getBackgroundImageUrl(FirstBackground), + seType: "ChangeBackground", + type: SnippetAction.SpecialEffect, + }); + } + if (FirstBgm) { + ret.actions.push({ + bgm: await getBgmUrl(FirstBgm), + delay: 0, + hasBgm: true, + hasSe: false, + isWait: SnippetProgressBehavior.WaitUnitilFinished, + playMode: SoundPlayMode[0], + se: "", + type: SnippetAction.Sound, + }); + } + + ret.characters = AppearCharacters.map((ap) => { + const chara2d = chara2Ds.find((ch) => ch.id === ap.Character2dId); + if (!chara2d) + return { + id: ap.Character2dId, + name: ap.CostumeType, + }; + switch (chara2d.characterType) { + case "game_character": { + return { + id: chara2d.characterId, + name: getCharaName(chara2d.characterId)!, + }; + } + case "mob": { + return { + id: chara2d.characterId, + name: + mobCharas?.find((mc) => mc.id === chara2d.characterId)?.name || + "", + }; + } + } + }); + + for (const snippet of Snippets) { + let action: { [key: string]: any } = {}; + switch (snippet.Action) { + case SnippetAction.Talk: + { + const talkData = TalkData[snippet.ReferenceIndex]; + // try get character + let chara2d: ICharacter2D | undefined; + const chara = { id: 0, name: "" }; + if (talkData.TalkCharacters[0].Character2dId) { + chara2d = chara2Ds.find( + (ch) => ch.id === talkData.TalkCharacters[0].Character2dId + )!; + chara.id = chara2d.characterId; + } + chara.name = talkData.WindowDisplayName; + + action = { + body: talkData.Body, + chara, + delay: snippet.Delay, + isWait: + snippet.ProgressBehavior === + SnippetProgressBehavior.WaitUnitilFinished, + type: snippet.Action, + voice: talkData.Voices.length + ? await getTalkVoiceUrl( + voiceMap, + ScenarioId, + talkData, + info.isCardStory, + info.isActionSet, + region, + chara2d + ) + : "", + }; + } + break; + case SnippetAction.SpecialEffect: + { + const specialEffect = SpecialEffectData[snippet.ReferenceIndex]; + const specialEffectType = + SpecialEffectType[specialEffect.EffectType]; + + action = { + body: specialEffect.StringVal, + delay: snippet.Delay, + isWait: + snippet.ProgressBehavior === + SnippetProgressBehavior.WaitUnitilFinished, + resource: + specialEffectType === "FullScreenText" + ? await getFullScreenTextVoiceUrl( + ScenarioId, + specialEffect.StringValSub + ) + : specialEffectType === "ChangeBackground" + ? await getBackgroundImageUrl(specialEffect.StringValSub) + : specialEffectType === "Movie" + ? await getMovieUrl(specialEffect.StringVal) + : "", + seType: specialEffectType, + type: snippet.Action, + }; + } + break; + case SnippetAction.Sound: + { + const soundData = SoundData[snippet.ReferenceIndex]; + + action = { + bgm: soundData.Bgm ? await getBgmUrl(soundData.Bgm) : "", + delay: snippet.Delay, + hasBgm: !!soundData.Bgm, + hasSe: !!soundData.Se, + isWait: + snippet.ProgressBehavior === + SnippetProgressBehavior.WaitUnitilFinished, + playMode: SoundPlayMode[soundData.PlayMode], + se: soundData.Se ? await getSoundEffectUrl(soundData.Se) : "", + type: snippet.Action, + }; + + // console.dir(action); + } + break; + default: { + action = { + delay: snippet.Delay, + isWait: + snippet.ProgressBehavior === + SnippetProgressBehavior.WaitUnitilFinished, + type: snippet.Action, + }; + } + } + + ret.actions.push(action); + } + return ret; + }, + [chara2Ds, getCharaName, mobCharas] + ); +} + +export async function getProcessedScenarioDataForLive2D( + info: IScenarioInfo, + region: ServerRegion +) { + const { data }: { data: IScenarioData } = await Axios.get( + await getRemoteAssetURL(info.scenarioDataUrl, undefined, "minio", region), + { + responseType: "json", + } + ); + const { Snippets, SpecialEffectData, SoundData, FirstBgm, FirstBackground } = + data; + + if (FirstBackground) { + const bgSnippet: Snippet = { + Action: SnippetAction.SpecialEffect, + ProgressBehavior: SnippetProgressBehavior.Now, + ReferenceIndex: SpecialEffectData.length, + Delay: 0, + }; + const spData: SpecialEffectData = { + EffectType: SpecialEffectType.ChangeBackground, + StringVal: FirstBackground, + StringValSub: FirstBackground, + Duration: 0, + IntVal: 0, + }; + Snippets.unshift(bgSnippet); + SpecialEffectData.push(spData); + } + if (FirstBgm) { + const bgmSnippet: Snippet = { + Action: SnippetAction.Sound, + ProgressBehavior: SnippetProgressBehavior.Now, + ReferenceIndex: SoundData.length, + Delay: 0, + }; + const soundData: SoundData = { + PlayMode: SoundPlayMode.CrossFade, + Bgm: FirstBgm, + Se: "", + Volume: 1, + SeBundleName: "", + Duration: 2.5, + }; + Snippets.unshift(bgmSnippet); + SoundData.push(soundData); + } + return data; +} + +export function useMediaUrlForLive2D() { + const [chara2Ds] = useCachedData("character2ds"); + + return useCallback( + async ( + info: IScenarioInfo, + snData: IScenarioData, + region: ServerRegion + ) => { + const ret: ILive2DAssetUrl[] = []; + if (!chara2Ds) throw new Error("Characters not loaded. Please retry."); + const voiceMap: { + [key: string]: Record; + } = {}; + const { ScenarioId, Snippets, TalkData, SpecialEffectData, SoundData } = + snData; + // get all urls + for (const snippet of Snippets) { + switch (snippet.Action) { + case SnippetAction.Talk: + { + const talkData = TalkData[snippet.ReferenceIndex]; + // try get character + let chara2d: ICharacter2D | undefined; + if (talkData.TalkCharacters[0].Character2dId) { + chara2d = chara2Ds.find( + (ch) => ch.id === talkData.TalkCharacters[0].Character2dId + )!; + } + const url: ILive2DAssetUrl[] = []; + for (const v of talkData.Voices) { + url.push({ + identifer: v.VoiceId, + type: Live2DAssetType.Talk, + url: await getTalkVoiceUrl( + voiceMap, + ScenarioId, + talkData, + info.isCardStory, + info.isActionSet, + region, + chara2d + ), + }); + } + for (const s of url) + if (!ret.map((r) => r.url).includes(s.url)) ret.push(s); + } + break; + case SnippetAction.SpecialEffect: + { + const seData = SpecialEffectData[snippet.ReferenceIndex]; + switch (seData.EffectType) { + case SpecialEffectType.ChangeBackground: + { + const identifer = seData.StringValSub; + const url = await getBackgroundImageUrl( + seData.StringValSub + ); + if (ret.map((r) => r.url).includes(url)) continue; + ret.push({ + identifer, + type: Live2DAssetType.BackgroundImage, + url, + }); + } + break; + case SpecialEffectType.FullScreenText: + { + const identifer = seData.StringValSub; + const url = await getFullScreenTextVoiceUrl( + ScenarioId, + seData.StringValSub + ); + if (ret.map((r) => r.url).includes(url)) continue; + ret.push({ + identifer, + type: Live2DAssetType.SoundEffect, + url, + }); + } + break; + } + } + break; + case SnippetAction.Sound: + { + const soundData = SoundData[snippet.ReferenceIndex]; + if (soundData.Bgm) { + const identifer = soundData.Bgm; + const url = await getBgmUrl(soundData.Bgm); + if (ret.map((r) => r.url).includes(url)) continue; + ret.push({ + identifer, + type: Live2DAssetType.BackgroundMusic, + url, + }); + } else if (soundData.Se) { + const identifer = soundData.Se; + const url = await getSoundEffectUrl(soundData.Se); + if (ret.map((r) => r.url).includes(url)) continue; + ret.push({ + identifer, + type: Live2DAssetType.SoundEffect, + url, + }); + } + } + break; + } + } + return ret; + }, + [chara2Ds] + ); +} + +export async function getBgmUrl(bgm: string) { + return await getRemoteAssetURL( + `sound/scenario/bgm/${bgm}_rip/${bgm}.mp3`, + undefined, + "minio" + ); +} + +export async function getBackgroundImageUrl(img: string) { + return await getRemoteAssetURL( + `scenario/background/${img}_rip/${img}.webp`, + undefined, + "minio" + ); +} + +export async function getFullScreenTextVoiceUrl( + ScenarioId: string, + voice: string +) { + return await getRemoteAssetURL( + `sound/scenario/voice/${ScenarioId}_rip/${voice}.mp3`, + undefined, + "minio" + ); +} + +export async function getMovieUrl(movie: string) { + return await getRemoteAssetURL( + `scenario/movie/${movie}_rip`, + undefined, + "minio" + ); +} + +export async function getSoundEffectUrl(se: string) { + const isEventSe = se.startsWith("se_event"); + const baseDir = isEventSe + ? `event_story/${se.split("_").slice(1, -1).join("_")}` + : "sound/scenario/se"; + const seBundleName = isEventSe + ? "scenario_se" + : se.endsWith("_b") + ? "se_pack00001_b" + : "se_pack00001"; + return await getRemoteAssetURL( + `${baseDir}/${seBundleName}_rip/${se}.mp3`, + undefined, + "minio" + ); +} + +export async function getTalkVoiceUrl( + voiceMap: { + [key: string]: Record; + }, + ScenarioId: string, + talkData: TalkData, + isCardStory: boolean, + isActionSet: boolean, + region: ServerRegion, + chara2d?: ICharacter2D +): Promise { + let voiceUrl = ""; + if (talkData.Voices.length) { + const VoiceId = talkData.Voices[0].VoiceId; + const isPartVoice = VoiceId.startsWith("partvoice") && !isActionSet; + if (isPartVoice) { + // part_voice + if (chara2d) { + voiceUrl = `sound/scenario/part_voice/${chara2d.assetName}_${chara2d.unit}_rip/${VoiceId}.mp3`; + } + } else { + // card, actionset, scenario + voiceUrl = `sound/${isCardStory ? "card_" : ""}${ + isActionSet ? "actionset" : "scenario" + }/voice/${ScenarioId}_rip/${VoiceId}.mp3`; + } + // Original codes + // let voiceUrl = talkData.Voices.length + // ? `sound/${isCardStory ? "card_" : ""}${ + // isActionSet ? "actionset" : "scenario" + // }/voice/${ScenarioId}_rip/${talkData.Voices[0].VoiceId}.mp3` + // : ""; + // if ( + // talkData.Voices.length && + // talkData.Voices[0].VoiceId.startsWith("partvoice") && + // !isActionSet + // ) { + // const chara2d = chara2Ds.find( + // (ch) => ch.id === talkData.TalkCharacters[0].Character2dId + // ); + // if (chara2d) { + // voiceUrl = `sound/scenario/part_voice/${chara2d.assetName}_${chara2d.unit}_rip/${talkData.Voices[0].VoiceId}.mp3`; + // } else { + // voiceUrl = ""; + // } + // } + return await getRemoteAssetURL( + // Get asset list in directory + await fixVoiceUrl(voiceMap, region, VoiceId, voiceUrl), + undefined, + "minio" + ); + } else return ""; +} From 75ad56ec39f23cee763f1e9995117d25d6f41d8c Mon Sep 17 00:00:00 2001 From: K-bai Date: Wed, 22 Jan 2025 18:26:31 +0800 Subject: [PATCH 03/10] feat(live2d): implement fullscreentext effect --- src/components/story-selector/AreaTalk.tsx | 1 + src/components/story-selector/CardStory.tsx | 2 + src/components/story-selector/EventStory.tsx | 1 + .../story-selector/SpecialStory.tsx | 1 + src/components/story-selector/UnitStory.tsx | 2 + src/story-scenerio.d.ts | 23 +++++ src/utils/Live2DPlayer/Live2DController.ts | 60 ++++++++++++- src/utils/Live2DPlayer/Live2DPlayer.ts | 4 + src/utils/Live2DPlayer/README.md | 9 +- .../Live2DPlayer/layer/FullScreenText.ts | 88 +++++++++++++++++++ src/utils/storyLoader.ts | 2 +- 11 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 src/utils/Live2DPlayer/layer/FullScreenText.ts diff --git a/src/components/story-selector/AreaTalk.tsx b/src/components/story-selector/AreaTalk.tsx index 3d19ab81..201b3235 100644 --- a/src/components/story-selector/AreaTalk.tsx +++ b/src/components/story-selector/AreaTalk.tsx @@ -186,6 +186,7 @@ const AreaTalk: React.FC<{ ); } } + return null; }} diff --git a/src/components/story-selector/CardStory.tsx b/src/components/story-selector/CardStory.tsx index 383c1e3d..e93312e9 100644 --- a/src/components/story-selector/CardStory.tsx +++ b/src/components/story-selector/CardStory.tsx @@ -150,6 +150,7 @@ const CardStory: React.FC<{ ); } } + return null; }} @@ -188,6 +189,7 @@ const CardStory: React.FC<{ ); } } + return null; }} diff --git a/src/components/story-selector/EventStory.tsx b/src/components/story-selector/EventStory.tsx index d523c5a4..aa196888 100644 --- a/src/components/story-selector/EventStory.tsx +++ b/src/components/story-selector/EventStory.tsx @@ -137,6 +137,7 @@ const EventStory: React.FC<{ ); } } + return null; }} diff --git a/src/components/story-selector/SpecialStory.tsx b/src/components/story-selector/SpecialStory.tsx index d2eb45e1..d6563895 100644 --- a/src/components/story-selector/SpecialStory.tsx +++ b/src/components/story-selector/SpecialStory.tsx @@ -110,6 +110,7 @@ const SpecialStory: React.FC<{ ); } } + return null; }} diff --git a/src/components/story-selector/UnitStory.tsx b/src/components/story-selector/UnitStory.tsx index c26a0ead..86ad63aa 100644 --- a/src/components/story-selector/UnitStory.tsx +++ b/src/components/story-selector/UnitStory.tsx @@ -114,6 +114,7 @@ const UnitStory: React.FC<{ ); } } + return null; }} @@ -163,6 +164,7 @@ const UnitStory: React.FC<{ } } } + return null; }} diff --git a/src/story-scenerio.d.ts b/src/story-scenerio.d.ts index ec7d95df..10ec42cd 100644 --- a/src/story-scenerio.d.ts +++ b/src/story-scenerio.d.ts @@ -312,6 +312,13 @@ export enum SpecialEffectType { */ AttachCharacterShader = 22, SimpleSelectable = 23, + /** + * black background, white text on the center of screen. + * used in unit story. + * @param StringVal Text + * @param StringValSub Voice + * @see https://www.bilibili.com/video/BV1kD4y1R7h9?p=2 + */ FullScreenText = 24, /** * @see {@link SpecialEffectType.ShakeScreen} @@ -393,6 +400,22 @@ export enum SpecialEffectType { * @see 03:46 https://www.bilibili.com/video/BV1p4421S7yh?p=5 */ BlackWipeOutBottom = 36, + /** + * Appear before full screen text. Maybe show black background? + * @param StringVal always empty string. + * @param StringValSub always empty string. + * @param Duration duration. + * @see https://www.bilibili.com/video/BV1kD4y1R7h9?p=2 + */ + FullScreenTextShow = 38, + /** + * Appear after full screen text. Maybe hide black background? + * @param StringVal always empty string. + * @param StringValSub always empty string. + * @param Duration duration. + * @see https://www.bilibili.com/video/BV1kD4y1R7h9?p=2 + */ + FullScreenTextHide = 39, } export interface SpecialEffectData { diff --git a/src/utils/Live2DPlayer/Live2DController.ts b/src/utils/Live2DPlayer/Live2DController.ts index bfb38bd5..4639ad8a 100644 --- a/src/utils/Live2DPlayer/Live2DController.ts +++ b/src/utils/Live2DPlayer/Live2DController.ts @@ -105,6 +105,9 @@ export class Live2DController extends Live2DPlayer { this.model_queue = this.create_model_queue(); log.log("Live2DController", "init."); + log.log("Live2DController", this.scenarioData); + log.log("Live2DController", this.scenarioResource); + log.log("Live2DController", this.modelData); } /** @@ -206,6 +209,10 @@ export class Live2DController extends Live2DPlayer { this.scenarioData.SpecialEffectData[action.ReferenceIndex]; if (action_detail.EffectType === SpecialEffectType.Telop) { return true; + } else if ( + action_detail.EffectType === SpecialEffectType.FullScreenText + ) { + return true; } } return false; @@ -346,7 +353,7 @@ export class Live2DController extends Live2DPlayer { this.layers.fullcolor.draw(0xffffff); await this.layers.fullcolor.show( action_detail.Duration * 1000, - false + true ); } break; @@ -738,6 +745,55 @@ export class Live2DController extends Live2DPlayer { ); } break; + case SpecialEffectType.FullScreenText: + { + log.log( + "Live2DController", + "SpecialEffect/FullScreenText", + action, + action_detail + ); + const sound = this.scenarioResource.find( + (s) => + s.identifer === action_detail.StringValSub && + s.type === Live2DAssetType.Talk + ); + if (sound) (sound.data as Howl).play(); + else + log.warn( + "Live2DController", + `${action_detail.StringValSub} not loaded, skip.` + ); + await this.layers.fullscreentext.animate( + action_detail.StringVal + ); + } + break; + case SpecialEffectType.FullScreenTextShow: + { + log.log( + "Live2DController", + "SpecialEffect/FullScreenTextShow", + action, + action_detail + ); + this.layers.fullscreentext.show( + action_detail.Duration * 1000, + true + ); + } + break; + case SpecialEffectType.FullScreenTextHide: + { + log.log( + "Live2DController", + "SpecialEffect/FullScreenTextHide", + action, + action_detail + ); + this.layers.fullscreentext.hide(action_detail.Duration * 1000); + } + break; default: log.warn( "Live2DController", @@ -955,7 +1011,7 @@ export class Live2DController extends Live2DPlayer { (s) => s.identifer === action_detail.Voices[0].VoiceId && s.type === Live2DAssetType.Talk - )!; + ); if (sound) { const costume = this.live2d_get_costume( action_detail.TalkCharacters[0].Character2dId diff --git a/src/utils/Live2DPlayer/Live2DPlayer.ts b/src/utils/Live2DPlayer/Live2DPlayer.ts index 441fe838..a992bafc 100644 --- a/src/utils/Live2DPlayer/Live2DPlayer.ts +++ b/src/utils/Live2DPlayer/Live2DPlayer.ts @@ -14,6 +14,7 @@ import SceneEffect from "./layer/SceneEffect"; import Live2D from "./layer/Live2D"; import Wipe from "./layer/Wipe"; import Sekai from "./layer/Sekai"; +import FullScreenText from "./layer/FullScreenText"; import AnimationController from "./animation/AnimationController"; @@ -25,6 +26,7 @@ export class Live2DPlayer { background: Background; fullcolor: Fullcolor; dialog: Dialog; + fullscreentext: FullScreenText; telop: Telop; flashback: Flashback; scene_effect: SceneEffect; @@ -60,6 +62,7 @@ export class Live2DPlayer { this.layers = { background: new Background(layer_data), fullcolor: new Fullcolor(layer_data), + fullscreentext: new FullScreenText(layer_data), telop: new Telop(layer_data), flashback: new Flashback(layer_data), scene_effect: new SceneEffect(layer_data), @@ -73,6 +76,7 @@ export class Live2DPlayer { app.stage.addChild(this.layers.scene_effect.root); app.stage.addChild(this.layers.dialog.root); app.stage.addChild(this.layers.telop.root); + app.stage.addChild(this.layers.fullscreentext.root); app.stage.addChild(this.layers.sekai.root); app.stage.addChild(this.layers.wipe.root); app.stage.addChild(this.layers.flashback.root); diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index 79c187ee..ebec6e35 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -12,14 +12,11 @@ - SpecialEffectType.41 - SpecialEffectType.43 - SpecialEffectType.40 - - SpecialEffectType.FullScreenText - SpecialEffectType.27 - SpecialEffectType.SimpleSelectable - SpecialEffectType.28 - SpecialEffectType.0 - SpecialEffectType.44 - - SpecialEffectType.38 - - SpecialEffectType.39 # CHANGE LOG @@ -78,6 +75,8 @@ - fix(live2d): loading progress sometimes wrong - commit 0efc83881d1fa1a9860ed165ff5f5a36666b0649 - fix(live2d): cross origin policy are not set for images and sounds -- commit this +- commit b3fc0d8d6f6be3c29ca6c4c19b1ffe4cc30f3021 - refactor(storyreader): create story loading module for text and live2d reader - - fix(live2d): smaller dialog line height \ No newline at end of file + - fix(live2d): smaller dialog line height +- commit this + - feat(live2d): implement fullscreentext effect \ No newline at end of file diff --git a/src/utils/Live2DPlayer/layer/FullScreenText.ts b/src/utils/Live2DPlayer/layer/FullScreenText.ts new file mode 100644 index 00000000..6f3c6b87 --- /dev/null +++ b/src/utils/Live2DPlayer/layer/FullScreenText.ts @@ -0,0 +1,88 @@ +import { Container, Text, TextStyle, Graphics } from "pixi.js"; +import type { ILive2DLayerData } from "../types.d"; +import BaseLayer from "./BaseLayer"; + +export default class FullScreenText extends BaseLayer { + structure: { + text_container?: Container; + text_c?: Text; + bg_graphic?: Graphics; + }; + constructor(data: ILive2DLayerData) { + super(data); + this.structure = {}; + } + + draw(text: string) { + this.root.removeChildren(); + + const bg_graphic = new Graphics(); + this.root.addChild(bg_graphic); + bg_graphic + .beginFill(0x000000, 1) + .drawRect(0, 0, this.screen_length, this.screen_length) + .endFill(); + + const text_container = new Container(); + this.root.addChild(text_container); + const text_c = new Text(text); + text_container.addChild(text_c); + + this.structure = { + text_container, + text_c, + bg_graphic, + }; + this.init = true; + this.set_style(); + } + draw_new_text(text: string) { + if (this.init) { + const new_text = new Text(text); + this.structure.text_container?.addChild(new_text); + this.structure.text_c?.destroy(); + this.structure.text_c = new_text; + this.set_style_text(); + } + } + set_style(stage_size?: [number, number]): void { + this.stage_size = stage_size ? stage_size : this.stage_size; + if (this.init) { + const bg = this.structure.bg_graphic!; + bg.x = 0; + bg.y = 0; + bg.scale.set( + this.stage_size[0] / this.screen_length, + this.stage_size[1] / this.screen_length + ); + this.set_style_text(); + } + } + set_style_text() { + const text = this.structure.text_c!; + text.anchor.set(0.5); + text.x = this.stage_size[0] * 0.5; + text.y = this.stage_size[1] * 0.5; + text.style = new TextStyle({ + fill: ["#ffffff"], + fontSize: this.em(28), + lineHeight: this.em(28) * 1.3, + breakWords: true, + wordWrap: true, + wordWrapWidth: this.stage_size[0], + }); + } + + async animate(text: string) { + this.draw(""); + for (let i = 1; i <= text.length; i++) { + // if aborted, jump to full text + if (this.animation_controller.abort_controller.signal.aborted) { + i = text.length; + } + // new text + this.draw_new_text(text.slice(0, i)); + await this.animation_controller.delay(50); + } + } +} diff --git a/src/utils/storyLoader.ts b/src/utils/storyLoader.ts index b8dfe8e2..bcb4204f 100644 --- a/src/utils/storyLoader.ts +++ b/src/utils/storyLoader.ts @@ -562,7 +562,7 @@ export function useMediaUrlForLive2D() { if (ret.map((r) => r.url).includes(url)) continue; ret.push({ identifer, - type: Live2DAssetType.SoundEffect, + type: Live2DAssetType.Talk, url, }); } From 034546c15162646bec4f4ee8fca70ca7ad01cef8 Mon Sep 17 00:00:00 2001 From: K-bai Date: Thu, 23 Jan 2025 00:22:55 +0800 Subject: [PATCH 04/10] feat(live2d): add support for none 16:9 screen in fullscreen mode --- .../StoryReaderLive2DCanvas.tsx | 20 ++++++--- src/utils/Live2DPlayer/Live2DController.ts | 43 ++++++++++++++----- src/utils/Live2DPlayer/Live2DPlayer.ts | 9 ++-- src/utils/Live2DPlayer/README.md | 6 ++- src/utils/Live2DPlayer/layer/BaseLayer.ts | 4 +- .../Live2DPlayer/layer/FullScreenText.ts | 30 ++----------- 6 files changed, 65 insertions(+), 47 deletions(-) diff --git a/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx b/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx index 46a4852d..2ba79a7e 100644 --- a/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx +++ b/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx @@ -59,9 +59,9 @@ const StoryReaderLive2DStage = forwardRef< }; }, []); function reloadStage() { - const live2d = controller.current?.current_costume; controller.current = new Live2DController(app, stageSize, controllerData); - if (live2d) controller.current.current_costume = live2d; + controller.current.layers.live2d.clear(); + controller.current.live2d_load_model(0); } return null; }); @@ -90,9 +90,17 @@ const StoryReaderLive2DCanvas: React.FC<{ useLayoutEffect(() => { const update_stage_size = () => { if (wrap.current) { - const styleWidth = wrap.current.clientWidth; - const styleHeight = (styleWidth * 9) / 16; - setStageSize([styleWidth, styleHeight]); + if (!document.fullscreenElement) { + // 16:9 if not fullscreen + const styleWidth = wrap.current.clientWidth; + const styleHeight = (styleWidth * 9) / 16; + setStageSize([styleWidth, styleHeight]); + } else { + // follow user screen size if fullscreen + const styleWidth = document.fullscreenElement.clientWidth; + const styleHeight = document.fullscreenElement.clientHeight; + setStageSize([styleWidth, styleHeight]); + } } }; window.addEventListener("resize", update_stage_size); @@ -153,6 +161,8 @@ const StoryReaderLive2DCanvas: React.FC<{ function refresh () { stage.current?.reloadStage(); + setScenarioStep(0); + setPlaying(false); } function goto () { diff --git a/src/utils/Live2DPlayer/Live2DController.ts b/src/utils/Live2DPlayer/Live2DController.ts index 4639ad8a..04fda3b7 100644 --- a/src/utils/Live2DPlayer/Live2DController.ts +++ b/src/utils/Live2DPlayer/Live2DController.ts @@ -117,7 +117,7 @@ export class Live2DController extends Live2DPlayer { * Load more than [x] models in the same scene will cause live2d sdk not update the models. * The number [x] is depends on the device memory...? So I chose 6 here, need more test. */ - create_model_queue = (queue_max = 6) => { + create_model_queue = (queue_max = 2) => { const current_costume: { cid: number; costume: string; @@ -260,7 +260,9 @@ export class Live2DController extends Live2DPlayer { const start_time = Date.now(); await this.live2d_load_model(Math.max(...action_in_parallel)); await Promise.all( - action_in_parallel.map((a) => this.apply_action(a, -offset_ms)) + action_in_parallel.map((a) => + this.apply_action(a, Math.min(-offset_ms + 1000, 0)) + ) ); offset_ms = Date.now() - start_time; } @@ -338,7 +340,11 @@ export class Live2DController extends Live2DPlayer { action, action_detail ); - await this.layers.fullcolor.hide(action_detail.Duration * 1000); + this.layers.fullcolor.draw(0xffffff); + await this.layers.fullcolor.hide( + action_detail.Duration * 1000, + true + ); } break; case SpecialEffectType.WhiteOut: @@ -365,7 +371,12 @@ export class Live2DController extends Live2DPlayer { action, action_detail ); - await this.layers.fullcolor.hide(action_detail.Duration * 1000); + this.layers.fullcolor.draw(0x000000); + this.layers.fullscreen_text.hide(100); + await this.layers.fullcolor.hide( + action_detail.Duration * 1000, + false + ); } break; case SpecialEffectType.BlackOut: @@ -404,7 +415,7 @@ export class Live2DController extends Live2DPlayer { action, action_detail ); - await this.layers.flashback.hide(100); + await this.layers.flashback.hide(100, true); } break; case SpecialEffectType.AttachCharacterShader: @@ -758,13 +769,16 @@ export class Live2DController extends Live2DPlayer { s.identifer === action_detail.StringValSub && s.type === Live2DAssetType.Talk ); - if (sound) (sound.data as Howl).play(); - else + if (sound) { + this.stop_sounds([Live2DAssetType.Talk]); + (sound.data as Howl).play(); + } else log.warn( "Live2DController", `${action_detail.StringValSub} not loaded, skip.` ); - await this.layers.fullscreentext.animate( + this.layers.fullscreen_text.show(500); + await this.layers.fullscreen_text.animate( action_detail.StringVal ); } @@ -777,7 +791,8 @@ export class Live2DController extends Live2DPlayer { action, action_detail ); - this.layers.fullscreentext.show( + this.layers.fullscreen_text_bg.draw(0x000000); + await this.layers.fullscreen_text_bg.show( action_detail.Duration * 1000, true ); @@ -791,7 +806,15 @@ export class Live2DController extends Live2DPlayer { action, action_detail ); - this.layers.fullscreentext.hide(action_detail.Duration * 1000); + this.layers.fullscreen_text_bg.draw(0x000000); + this.layers.fullscreen_text_bg.hide( + action_detail.Duration * 1000, + true + ); + await this.layers.fullscreen_text.hide( + action_detail.Duration * 1000, + true + ); } break; default: diff --git a/src/utils/Live2DPlayer/Live2DPlayer.ts b/src/utils/Live2DPlayer/Live2DPlayer.ts index a992bafc..db01d8c0 100644 --- a/src/utils/Live2DPlayer/Live2DPlayer.ts +++ b/src/utils/Live2DPlayer/Live2DPlayer.ts @@ -26,7 +26,8 @@ export class Live2DPlayer { background: Background; fullcolor: Fullcolor; dialog: Dialog; - fullscreentext: FullScreenText; + fullscreen_text: FullScreenText; + fullscreen_text_bg: Fullcolor; telop: Telop; flashback: Flashback; scene_effect: SceneEffect; @@ -62,7 +63,8 @@ export class Live2DPlayer { this.layers = { background: new Background(layer_data), fullcolor: new Fullcolor(layer_data), - fullscreentext: new FullScreenText(layer_data), + fullscreen_text: new FullScreenText(layer_data), + fullscreen_text_bg: new Fullcolor(layer_data), telop: new Telop(layer_data), flashback: new Flashback(layer_data), scene_effect: new SceneEffect(layer_data), @@ -76,11 +78,12 @@ export class Live2DPlayer { app.stage.addChild(this.layers.scene_effect.root); app.stage.addChild(this.layers.dialog.root); app.stage.addChild(this.layers.telop.root); - app.stage.addChild(this.layers.fullscreentext.root); app.stage.addChild(this.layers.sekai.root); app.stage.addChild(this.layers.wipe.root); app.stage.addChild(this.layers.flashback.root); app.stage.addChild(this.layers.fullcolor.root); + app.stage.addChild(this.layers.fullscreen_text_bg.root); + app.stage.addChild(this.layers.fullscreen_text.root); log.log("Live2DPlayer", `player init.`); } diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index ebec6e35..2bf489ba 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -1,5 +1,6 @@ # TODO +- volume settings - bgm fade in and out - VoiceId more than one - more SpecialEffectType.PlayScenarioEffect @@ -78,5 +79,8 @@ - commit b3fc0d8d6f6be3c29ca6c4c19b1ffe4cc30f3021 - refactor(storyreader): create story loading module for text and live2d reader - fix(live2d): smaller dialog line height +- commit 2cb3c8007ebbb2ae3bb2cbf9a4877cf989ecb219 + - feat(live2d): implement fullscreentext effect - commit this - - feat(live2d): implement fullscreentext effect \ No newline at end of file + - feat(live2d): add support for none 16:9 screen in fullscreen mode + - fix(live2d): fullscreen text background not work properly \ No newline at end of file diff --git a/src/utils/Live2DPlayer/layer/BaseLayer.ts b/src/utils/Live2DPlayer/layer/BaseLayer.ts index a061f3fc..deb5feb4 100644 --- a/src/utils/Live2DPlayer/layer/BaseLayer.ts +++ b/src/utils/Live2DPlayer/layer/BaseLayer.ts @@ -41,7 +41,7 @@ export default abstract class BaseLayer { public show = async (time: number, force = false) => { if (this.root.alpha !== 1 || force) { - this.animation_controller.progress_wrapper((progress) => { + await this.animation_controller.progress_wrapper((progress) => { this.root.alpha = progress; }, time); } @@ -49,7 +49,7 @@ export default abstract class BaseLayer { public hide = async (time: number, force = false) => { if (this.root.alpha !== 0 || force) { - this.animation_controller.progress_wrapper((progress) => { + await this.animation_controller.progress_wrapper((progress) => { this.root.alpha = 1 - progress; }, time); } diff --git a/src/utils/Live2DPlayer/layer/FullScreenText.ts b/src/utils/Live2DPlayer/layer/FullScreenText.ts index 6f3c6b87..c0b8be92 100644 --- a/src/utils/Live2DPlayer/layer/FullScreenText.ts +++ b/src/utils/Live2DPlayer/layer/FullScreenText.ts @@ -1,12 +1,10 @@ -import { Container, Text, TextStyle, Graphics } from "pixi.js"; +import { Text, TextStyle } from "pixi.js"; import type { ILive2DLayerData } from "../types.d"; import BaseLayer from "./BaseLayer"; export default class FullScreenText extends BaseLayer { structure: { - text_container?: Container; text_c?: Text; - bg_graphic?: Graphics; }; constructor(data: ILive2DLayerData) { super(data); @@ -16,22 +14,11 @@ export default class FullScreenText extends BaseLayer { draw(text: string) { this.root.removeChildren(); - const bg_graphic = new Graphics(); - this.root.addChild(bg_graphic); - bg_graphic - .beginFill(0x000000, 1) - .drawRect(0, 0, this.screen_length, this.screen_length) - .endFill(); - - const text_container = new Container(); - this.root.addChild(text_container); const text_c = new Text(text); - text_container.addChild(text_c); + this.root.addChild(text_c); this.structure = { - text_container, text_c, - bg_graphic, }; this.init = true; this.set_style(); @@ -39,7 +26,7 @@ export default class FullScreenText extends BaseLayer { draw_new_text(text: string) { if (this.init) { const new_text = new Text(text); - this.structure.text_container?.addChild(new_text); + this.root.addChild(new_text); this.structure.text_c?.destroy(); this.structure.text_c = new_text; this.set_style_text(); @@ -47,16 +34,7 @@ export default class FullScreenText extends BaseLayer { } set_style(stage_size?: [number, number]): void { this.stage_size = stage_size ? stage_size : this.stage_size; - if (this.init) { - const bg = this.structure.bg_graphic!; - bg.x = 0; - bg.y = 0; - bg.scale.set( - this.stage_size[0] / this.screen_length, - this.stage_size[1] / this.screen_length - ); - this.set_style_text(); - } + if (this.init) this.set_style_text(); } set_style_text() { const text = this.structure.text_c!; From fd8531bb829f9d899b795e3b1f5af3dae7e854ca Mon Sep 17 00:00:00 2001 From: K-bai Date: Thu, 23 Jan 2025 01:40:54 +0800 Subject: [PATCH 05/10] refactor(live2d): split live2d player action into multiple files --- src/utils/Live2DPlayer/Live2DController.ts | 897 +----------------- src/utils/Live2DPlayer/Live2DPlayer.ts | 6 +- src/utils/Live2DPlayer/README.md | 6 +- .../Live2DPlayer/action/character_layout.ts | 204 ++++ .../Live2DPlayer/action/character_motion.ts | 38 + src/utils/Live2DPlayer/action/index.ts | 39 + src/utils/Live2DPlayer/action/sound.ts | 69 ++ .../special_effect/AmbientColorEvening.ts | 28 + .../special_effect/AmbientColorNight.ts | 28 + .../special_effect/AmbientColorNormal.ts | 18 + .../special_effect/AttachCharacterShader.ts | 54 ++ .../action/special_effect/BlackIn.ts | 15 + .../action/special_effect/BlackOut.ts | 15 + .../special_effect/BlackWipeInBottom.ts | 23 + .../action/special_effect/BlackWipeInLeft.ts | 23 + .../action/special_effect/BlackWipeInRight.ts | 23 + .../action/special_effect/BlackWipeInTop.ts | 23 + .../special_effect/BlackWipeOutBottom.ts | 23 + .../action/special_effect/BlackWipeOutLeft.ts | 23 + .../special_effect/BlackWipeOutRight.ts | 23 + .../action/special_effect/BlackWipeOutTop.ts | 23 + .../action/special_effect/ChangeBackground.ts | 24 + .../action/special_effect/FlashbackIn.ts | 19 + .../action/special_effect/FlashbackOut.ts | 18 + .../action/special_effect/FullScreenText.ts | 33 + .../special_effect/FullScreenTextHide.ts | 26 + .../special_effect/FullScreenTextShow.ts | 22 + .../special_effect/PlayScenarioEffect.ts | 18 + .../action/special_effect/SekaiIn.ts | 14 + .../action/special_effect/SekaiOut.ts | 15 + .../action/special_effect/ShakeScreen.ts | 29 + .../action/special_effect/ShakeWindow.ts | 28 + .../special_effect/StopScenarioEffect.ts | 18 + .../action/special_effect/StopShakeScreen.ts | 19 + .../action/special_effect/StopShakeWindow.ts | 18 + .../action/special_effect/Telop.ts | 14 + .../action/special_effect/WhiteIn.ts | 14 + .../action/special_effect/WhiteOut.ts | 15 + .../action/special_effect/index.ts | 146 +++ src/utils/Live2DPlayer/action/talk.ts | 54 ++ 40 files changed, 1251 insertions(+), 894 deletions(-) create mode 100644 src/utils/Live2DPlayer/action/character_layout.ts create mode 100644 src/utils/Live2DPlayer/action/character_motion.ts create mode 100644 src/utils/Live2DPlayer/action/index.ts create mode 100644 src/utils/Live2DPlayer/action/sound.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/AmbientColorEvening.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/AmbientColorNight.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/AmbientColorNormal.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/AttachCharacterShader.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackIn.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackOut.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeInBottom.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeInLeft.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeInRight.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeInTop.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeOutBottom.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeOutLeft.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeOutRight.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/BlackWipeOutTop.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/ChangeBackground.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/FlashbackIn.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/FlashbackOut.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/FullScreenTextHide.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/FullScreenTextShow.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/PlayScenarioEffect.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/SekaiIn.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/SekaiOut.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/ShakeScreen.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/ShakeWindow.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/StopScenarioEffect.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/StopShakeScreen.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/StopShakeWindow.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/Telop.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/WhiteIn.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/WhiteOut.ts create mode 100644 src/utils/Live2DPlayer/action/special_effect/index.ts create mode 100644 src/utils/Live2DPlayer/action/talk.ts diff --git a/src/utils/Live2DPlayer/Live2DController.ts b/src/utils/Live2DPlayer/Live2DController.ts index 04fda3b7..d931ae5c 100644 --- a/src/utils/Live2DPlayer/Live2DController.ts +++ b/src/utils/Live2DPlayer/Live2DController.ts @@ -1,19 +1,12 @@ import { Live2DPlayer } from "./Live2DPlayer"; -import { Curve } from "./animation/Curve"; -import { ColorMatrixFilter } from "pixi.js"; import type { Application } from "pixi.js"; -import { Howl, Howler } from "howler"; +import { Howl } from "howler"; import { log } from "./log"; import { IScenarioData, SnippetAction, SpecialEffectType, SnippetProgressBehavior, - SoundPlayMode, - CharacterLayoutType, - CharacterLayoutPosition, - CharacterLayoutMoveSpeedType, - SeAttachCharacterShaderType, } from "../../types.d"; import { @@ -24,58 +17,13 @@ import { ILive2DControllerData, } from "./types.d"; -function side_to_position(side: number, offset: number) { - let position: [number, number] = [0.5, 0.5]; - switch (side) { - case CharacterLayoutPosition.Center: - position = [0.5, 0.5]; - break; - case CharacterLayoutPosition.Left: - position = [0.3, 0.5]; - break; - case CharacterLayoutPosition.Right: - position = [0.7, 0.5]; - break; - case CharacterLayoutPosition.LeftEdge: - position = [-0.5, 0.5]; - break; - case CharacterLayoutPosition.RightEdge: - position = [1.5, 0.5]; - break; - case CharacterLayoutPosition.BottomEdge: - position = [0.5, 1.5]; - break; - case CharacterLayoutPosition.BottomLeftEdge: - position = [0.3, 1.5]; - break; - case CharacterLayoutPosition.BottomRightEdge: - position = [0.7, 1.5]; - break; - default: - position = [0.5, 0.5]; - } - position[0] += offset / 2000; - return position; -} - -function move_speed(t: CharacterLayoutMoveSpeedType) { - switch (t) { - case CharacterLayoutMoveSpeedType.Fast: - return 300; - case CharacterLayoutMoveSpeedType.Normal: - return 500; - case CharacterLayoutMoveSpeedType.Slow: - return 700; - default: - return 300; - } -} +import single_action from "./action"; export class Live2DController extends Live2DPlayer { - private scenarioData: IScenarioData; - private scenarioResource: ILive2DCachedAsset[]; - private modelData: ILive2DModelDataCollection[]; - private model_queue: string[][]; + scenarioData: IScenarioData; + scenarioResource: ILive2DCachedAsset[]; + modelData: ILive2DModelDataCollection[]; + model_queue: string[][]; current_costume: { cid: number; costume: string; @@ -117,7 +65,7 @@ export class Live2DController extends Live2DPlayer { * Load more than [x] models in the same scene will cause live2d sdk not update the models. * The number [x] is depends on the device memory...? So I chose 6 here, need more test. */ - create_model_queue = (queue_max = 2) => { + create_model_queue = (queue_max = 6) => { const current_costume: { cid: number; costume: string; @@ -297,836 +245,7 @@ export class Live2DController extends Live2DPlayer { await this.animate.delay( Math.max(action.Delay * 1000 + delay_offset_ms, 0) ); - switch (action.Action) { - case SnippetAction.SpecialEffect: - { - const action_detail = - this.scenarioData.SpecialEffectData[action.ReferenceIndex]; - switch (action_detail.EffectType) { - case SpecialEffectType.ChangeBackground: - { - log.log( - "Live2DController", - "SpecialEffect/ChangeBackground", - action, - action_detail - ); - const data = this.scenarioResource.find( - (s) => - s.identifer === action_detail.StringValSub && - s.type === Live2DAssetType.BackgroundImage - )?.data as HTMLImageElement; - this.layers.background.draw(data); - } - break; - case SpecialEffectType.Telop: - { - log.log( - "Live2DController", - "SpecialEffect/Telop", - action, - action_detail - ); - const data = action_detail.StringVal; - this.layers.telop.draw(data); - await this.layers.telop.show(300, true); - } - break; - case SpecialEffectType.WhiteIn: - { - log.log( - "Live2DController", - "SpecialEffect/WhiteIn", - action, - action_detail - ); - this.layers.fullcolor.draw(0xffffff); - await this.layers.fullcolor.hide( - action_detail.Duration * 1000, - true - ); - } - break; - case SpecialEffectType.WhiteOut: - { - log.log( - "Live2DController", - "SpecialEffect/WhiteOut", - action, - action_detail - ); - this.layers.dialog.hide(100); - this.layers.fullcolor.draw(0xffffff); - await this.layers.fullcolor.show( - action_detail.Duration * 1000, - true - ); - } - break; - case SpecialEffectType.BlackIn: - { - log.log( - "Live2DController", - "SpecialEffect/BlackIn", - action, - action_detail - ); - this.layers.fullcolor.draw(0x000000); - this.layers.fullscreen_text.hide(100); - await this.layers.fullcolor.hide( - action_detail.Duration * 1000, - false - ); - } - break; - case SpecialEffectType.BlackOut: - { - log.log( - "Live2DController", - "SpecialEffect/BlackOut", - action, - action_detail - ); - this.layers.dialog.hide(100); - this.layers.fullcolor.draw(0x000000); - await this.layers.fullcolor.show( - action_detail.Duration * 1000, - true - ); - } - break; - case SpecialEffectType.FlashbackIn: - { - log.log( - "Live2DController", - "SpecialEffect/FlashbackIn", - action, - action_detail - ); - this.layers.flashback.draw(); - await this.layers.flashback.show(100, true); - } - break; - case SpecialEffectType.FlashbackOut: - { - log.log( - "Live2DController", - "SpecialEffect/FlashbackOut", - action, - action_detail - ); - await this.layers.flashback.hide(100, true); - } - break; - case SpecialEffectType.AttachCharacterShader: - { - log.log( - "Live2DController", - "SpecialEffect/AttachCharacterShader", - action, - action_detail - ); - switch (action_detail.StringVal) { - case SeAttachCharacterShaderType.Hologram: - { - this.layers.live2d.add_effect( - this.live2d_get_costume(action_detail.IntVal)!, - "hologram" - ); - this.current_costume - .find((c) => c.cid === action_detail.IntVal)! - .animations.push("hologram"); - } - break; - case SeAttachCharacterShaderType.None: - case SeAttachCharacterShaderType.Empty: - { - this.layers.live2d.remove_effect( - this.live2d_get_costume(action_detail.IntVal)!, - "hologram" - ); - this.current_costume.find( - (c) => c.cid === action_detail.IntVal - )!.animations = []; - } - break; - default: - log.warn( - "Live2DController", - `${SnippetAction[action.Action]}/${SpecialEffectType[action_detail.EffectType]}/${(SeAttachCharacterShaderType as any)[action_detail.StringVal]} not implemented!`, - action, - action_detail - ); - } - } - break; - case SpecialEffectType.PlayScenarioEffect: - { - log.log( - "Live2DController", - "SpecialEffect/PlayScenarioEffect", - action, - action_detail - ); - this.layers.scene_effect.draw(action_detail.StringVal); - } - break; - case SpecialEffectType.StopScenarioEffect: - { - log.log( - "Live2DController", - "SpecialEffect/StopScenarioEffect", - action, - action_detail - ); - this.layers.scene_effect.remove(action_detail.StringVal); - } - break; - case SpecialEffectType.ShakeScreen: - { - log.log( - "Live2DController", - "SpecialEffect/ShakeScreen", - action, - action_detail - ); - const time_ms = action_detail.Duration * 1000; - const freq = 30; - const amp = 0.01 * this.stage_size[1]; - const curve_x = new Curve() - .wiggle(Math.floor((time_ms / 1000) * freq)) - .map_range(-amp, amp); - const curve_y = new Curve() - .wiggle(Math.floor((time_ms / 1000) * freq)) - .map_range(-amp, amp); - this.layers.background.shake(curve_x, curve_y, time_ms); - this.layers.live2d.shake(curve_x, curve_y, time_ms); - } - break; - case SpecialEffectType.ShakeWindow: - { - log.log( - "Live2DController", - "SpecialEffect/ShakeWindow", - action, - action_detail - ); - const time_ms = action_detail.Duration * 1000; - const freq = 30; - const amp = 0.01 * this.stage_size[1]; - const curve_x = new Curve() - .wiggle(Math.floor((time_ms / 1000) * freq)) - .map_range(-amp, amp); - const curve_y = new Curve() - .wiggle(Math.floor((time_ms / 1000) * freq)) - .map_range(-amp, amp); - this.layers.dialog.shake(curve_x, curve_y, time_ms); - } - break; - case SpecialEffectType.StopShakeScreen: - { - log.log( - "Live2DController", - "SpecialEffect/StopShakeScreen", - action, - action_detail - ); - this.layers.background.stop_shake(); - this.layers.live2d.stop_shake(); - } - break; - case SpecialEffectType.StopShakeWindow: - { - log.log( - "Live2DController", - "SpecialEffect/StopShakeWindow", - action, - action_detail - ); - this.layers.dialog.stop_shake(); - } - break; - case SpecialEffectType.AmbientColorNormal: - { - log.log( - "Live2DController", - "SpecialEffect/AmbientColorNormal", - action, - action_detail - ); - this.layers.live2d.remove_filter(); - } - break; - case SpecialEffectType.AmbientColorEvening: - { - log.log( - "Live2DController", - "SpecialEffect/AmbientColorEvening", - action, - action_detail - ); - this.layers.live2d.remove_filter(); - this.layers.live2d.add_color_filter( - [0.9, 0, 0, 0, 0], - [0, 0.9, 0, 0, 0], - [0, 0, 0.8, 0, 0], - [0, 0, 0, 1, 0] - ); - const filter = new ColorMatrixFilter(); - filter.saturate(-0.1); - this.layers.live2d.add_filter(filter); - } - break; - case SpecialEffectType.AmbientColorNight: - { - log.log( - "Live2DController", - "SpecialEffect/AmbientColorNight", - action, - action_detail - ); - this.layers.live2d.remove_filter(); - this.layers.live2d.add_color_filter( - [0.85, 0, 0, 0, 0], - [0, 0.85, 0, 0, 0], - [0, 0, 0.9, 0, 0], - [0, 0, 0, 1, 0] - ); - const filter = new ColorMatrixFilter(); - filter.saturate(-0.1); - this.layers.live2d.add_filter(filter); - } - break; - case SpecialEffectType.BlackWipeInLeft: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeInLeft", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - false, - "right", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.BlackWipeOutLeft: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeOutLeft", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - true, - "left", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.BlackWipeInRight: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeInRight", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - false, - "left", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.BlackWipeOutRight: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeOutRight", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - true, - "right", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.BlackWipeInTop: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeInTop", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - false, - "bottom", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.BlackWipeOutTop: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeOutTop", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - true, - "top", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.BlackWipeInBottom: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeInBottom", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - false, - "top", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.BlackWipeOutBottom: - { - log.log( - "Live2DController", - "SpecialEffect/BlackWipeOutBottom", - action, - action_detail - ); - this.layers.wipe.draw(); - await this.layers.wipe.animate( - true, - "bottom", - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.SekaiIn: - { - log.log( - "Live2DController", - "SpecialEffect/SekaiIn", - action, - action_detail - ); - this.layers.fullcolor.hide(action_detail.Duration * 1000); - await this.layers.sekai.draw( - false, - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.SekaiOut: - { - log.log( - "Live2DController", - "SpecialEffect/SekaiOut", - action, - action_detail - ); - this.layers.fullcolor.draw(0xffffff); - this.layers.fullcolor.show(action_detail.Duration * 1000, true); - await this.layers.sekai.draw( - true, - action_detail.Duration * 1000 - ); - } - break; - case SpecialEffectType.FullScreenText: - { - log.log( - "Live2DController", - "SpecialEffect/FullScreenText", - action, - action_detail - ); - const sound = this.scenarioResource.find( - (s) => - s.identifer === action_detail.StringValSub && - s.type === Live2DAssetType.Talk - ); - if (sound) { - this.stop_sounds([Live2DAssetType.Talk]); - (sound.data as Howl).play(); - } else - log.warn( - "Live2DController", - `${action_detail.StringValSub} not loaded, skip.` - ); - this.layers.fullscreen_text.show(500); - await this.layers.fullscreen_text.animate( - action_detail.StringVal - ); - } - break; - case SpecialEffectType.FullScreenTextShow: - { - log.log( - "Live2DController", - "SpecialEffect/FullScreenTextShow", - action, - action_detail - ); - this.layers.fullscreen_text_bg.draw(0x000000); - await this.layers.fullscreen_text_bg.show( - action_detail.Duration * 1000, - true - ); - } - break; - case SpecialEffectType.FullScreenTextHide: - { - log.log( - "Live2DController", - "SpecialEffect/FullScreenTextHide", - action, - action_detail - ); - this.layers.fullscreen_text_bg.draw(0x000000); - this.layers.fullscreen_text_bg.hide( - action_detail.Duration * 1000, - true - ); - await this.layers.fullscreen_text.hide( - action_detail.Duration * 1000, - true - ); - } - break; - default: - log.warn( - "Live2DController", - `${SnippetAction[action.Action]}/${SpecialEffectType[action_detail.EffectType]} not implemented!`, - action, - action_detail - ); - } - } - break; - case SnippetAction.CharacterLayout: - { - const action_detail = - this.scenarioData.LayoutData[action.ReferenceIndex]; - this.layers.telop.hide(500); - switch (action_detail.Type) { - case CharacterLayoutType.Motion: - { - log.log( - "Live2DController", - "CharacterLayout/Motion", - action, - action_detail - ); - const costume = this.live2d_get_costume( - action_detail.Character2dId - )!; - // Step 1: Apply motions and expressions. - const motion = this.apply_live2d_motion( - costume, - action_detail.MotionName, - action_detail.FacialName - ); - // (Same time) Move from current position to SideTo position or not move. - const to = side_to_position( - action_detail.SideTo, - action_detail.SideToOffsetX - ); - const move = this.layers.live2d.move( - costume, - undefined, - to, - move_speed(action_detail.MoveSpeedType) - ); - - await move; - await motion; - } - break; - case CharacterLayoutType.Appear: - { - log.log( - "Live2DController", - "CharacterLayout/Appear", - action, - action_detail - ); - // update CostumeType - let costume = ""; - if (action_detail.CostumeType !== "") { - costume = this.live2d_set_costume( - action_detail.Character2dId, - action_detail.CostumeType - ); - } else { - costume = this.live2d_get_costume( - action_detail.Character2dId - )!; - } - // Step 1: Apply motions and expressions. (To get the finish pose.) - await this.apply_live2d_motion( - costume, - action_detail.MotionName, - action_detail.FacialName - ); - // Step 2: Show. (after motion finished) - const show = this.layers.live2d.show_model(costume, 200); - this.live2d_set_appear(action_detail.Character2dId); - // (Same time) Move from SideFrom position to SideTo position or at SideFrom position. - const from = side_to_position( - action_detail.SideFrom, - action_detail.SideFromOffsetX - ); - const to = side_to_position( - action_detail.SideTo, - action_detail.SideToOffsetX - ); - let move; - if (from[0] === to[0] && from[1] === to[1]) { - this.layers.live2d.set_position(costume, from); - } else { - move = this.layers.live2d.move( - costume, - from, - to, - move_speed(action_detail.MoveSpeedType) - ); - } - // (Same time) Apply the same motions and expressions again. - this.animate - .delay(10) - .then(() => - this.apply_live2d_motion( - costume, - action_detail.MotionName, - action_detail.FacialName - ) - ); - - await show; - await move; - //await motion; - } - break; - case CharacterLayoutType.Clear: - { - log.log( - "Live2DController", - "CharacterLayout/Clear", - action, - action_detail - ); - const costume = this.live2d_get_costume( - action_detail.Character2dId - )!; - // Step 1: Move from SideFrom position to SideTo position or not move. - const from = side_to_position( - action_detail.SideFrom, - action_detail.SideFromOffsetX - ); - const to = side_to_position( - action_detail.SideTo, - action_detail.SideToOffsetX - ); - if (!(from[0] === to[0] && from[1] === to[1])) { - await this.layers.live2d.move( - costume, - from, - to, - move_speed(action_detail.MoveSpeedType) - ); - } - // Step 2: Wait for the model exist at least 2 seconds. - await this.live2d_stay(action_detail.Character2dId, 2000); - // Step 3: Hide. - await this.layers.live2d.hide_model(costume, 200); - } - break; - default: - log.warn( - "Live2DController", - `${SnippetAction[action.Action]}/${CharacterLayoutType[action_detail.Type]} not implemented!`, - action, - action_detail - ); - } - } - break; - case SnippetAction.CharacterMotion: - { - const action_detail = - this.scenarioData.LayoutData[action.ReferenceIndex]; - switch (action_detail.Type) { - case CharacterLayoutType.CharacterMotion: - { - log.log( - "Live2DController", - "CharacterMotion/CharacterMotion", - action, - action_detail - ); - // Step 1: Apply motions and expressions. - await this.apply_live2d_motion( - this.live2d_get_costume(action_detail.Character2dId)!, - action_detail.MotionName, - action_detail.FacialName - ); - } - break; - default: - log.warn( - "Live2DController", - `${SnippetAction[action.Action]}/${CharacterLayoutType[action_detail.Type]} not implemented!`, - action, - action_detail - ); - } - } - break; - case SnippetAction.Talk: - { - const action_detail = - this.scenarioData.TalkData[action.ReferenceIndex]; - log.log("Live2DController", "Talk", action, action_detail); - //clear - await this.layers.telop.hide(200); - // show dialog - const dialog = this.layers.dialog.animate( - action_detail.WindowDisplayName, - action_detail.Body - ); - await this.layers.dialog.show(200); - // motion - const motion = action_detail.Motions.map((m) => { - this.apply_live2d_motion( - this.live2d_get_costume(m.Character2dId)!, - m.MotionName, - m.FacialName - ); - }); - // sound - if (action_detail.Voices.length > 0) { - this.stop_sounds([Live2DAssetType.Talk]); - const sound = this.scenarioResource.find( - (s) => - s.identifer === action_detail.Voices[0].VoiceId && - s.type === Live2DAssetType.Talk - ); - if (sound) { - const costume = this.live2d_get_costume( - action_detail.TalkCharacters[0].Character2dId - ); - if (costume) { - this.layers.live2d.speak(costume, sound.url); - } else { - (sound.data as Howl).play(); - } - } else - log.warn( - "Live2DController", - `${action_detail.Voices[0].VoiceId} not loaded, skip.` - ); - } - // wait motion and text animation - await Promise.all(motion); - await dialog; - } - break; - case SnippetAction.Sound: - { - const action_detail = - this.scenarioData.SoundData[action.ReferenceIndex]; - log.log("Live2DController", "Sound", action, action_detail); - if (action_detail.Bgm) { - if (action_detail.Bgm === "bgm00000") { - Howler.stop(); - } else { - const sound = this.scenarioResource.find( - (s) => - s.identifer === action_detail.Bgm && - s.type === Live2DAssetType.BackgroundMusic - )?.data as Howl; - sound.loop(true); - this.stop_sounds([Live2DAssetType.BackgroundMusic]); - sound.volume(0.8); - sound.play(); - } - } else if (action_detail.Se) { - const sound = this.scenarioResource.find( - (s) => - s.identifer === action_detail.Se && - s.type === Live2DAssetType.SoundEffect - )?.data; - if (sound) { - switch (action_detail.PlayMode) { - case SoundPlayMode.Stop: - { - (sound as Howl).stop(); - } - break; - case SoundPlayMode.SpecialSePlay: - { - (sound as Howl).loop(true); - (sound as Howl).play(); - } - break; - case SoundPlayMode.CrossFade: - { - (sound as Howl).loop(false); - (sound as Howl).play(); - } - break; - case SoundPlayMode.Stack: - { - (sound as Howl).loop(false); - (sound as Howl).play(); - } - break; - default: - log.warn( - "Live2DController", - `Sound/SoundPlayMode:${action_detail.PlayMode} not implemented!`, - action - ); - } - } else - log.warn( - "Live2DController", - `${action_detail.Se} not loaded, skip.` - ); - } - } - break; - default: - log.warn( - "Live2DController", - `${SnippetAction[action.Action]} not implemented!`, - action - ); - } + await single_action(this, action); }; apply_live2d_motion = async ( costume: string, diff --git a/src/utils/Live2DPlayer/Live2DPlayer.ts b/src/utils/Live2DPlayer/Live2DPlayer.ts index db01d8c0..2c16b5f5 100644 --- a/src/utils/Live2DPlayer/Live2DPlayer.ts +++ b/src/utils/Live2DPlayer/Live2DPlayer.ts @@ -20,9 +20,9 @@ import AnimationController from "./animation/AnimationController"; export class Live2DPlayer { app: Application; - protected stage_size: [number, number]; - public animate: AnimationController; - public layers: { + stage_size: [number, number]; + animate: AnimationController; + layers: { background: Background; fullcolor: Fullcolor; dialog: Dialog; diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index 2bf489ba..d141d917 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -81,6 +81,8 @@ - fix(live2d): smaller dialog line height - commit 2cb3c8007ebbb2ae3bb2cbf9a4877cf989ecb219 - feat(live2d): implement fullscreentext effect -- commit this +- commit 9eb807e1e0be21c0e1e2a2d6794714511e2a73a6 - feat(live2d): add support for none 16:9 screen in fullscreen mode - - fix(live2d): fullscreen text background not work properly \ No newline at end of file + - fix(live2d): fullscreen text background not work properly +- commit this + - refactor(live2d): split live2d player action into multiple files \ No newline at end of file diff --git a/src/utils/Live2DPlayer/action/character_layout.ts b/src/utils/Live2DPlayer/action/character_layout.ts new file mode 100644 index 00000000..26728dc2 --- /dev/null +++ b/src/utils/Live2DPlayer/action/character_layout.ts @@ -0,0 +1,204 @@ +import type { Live2DController } from "../Live2DController"; +import type { Snippet } from "../../../types.d"; +import { SnippetAction } from "../../../types.d"; +import { + CharacterLayoutType, + CharacterLayoutPosition, + CharacterLayoutMoveSpeedType, +} from "../../../types.d"; +import { log } from "../log"; + +function side_to_position(side: number, offset: number) { + let position: [number, number] = [0.5, 0.5]; + switch (side) { + case CharacterLayoutPosition.Center: + position = [0.5, 0.5]; + break; + case CharacterLayoutPosition.Left: + position = [0.3, 0.5]; + break; + case CharacterLayoutPosition.Right: + position = [0.7, 0.5]; + break; + case CharacterLayoutPosition.LeftEdge: + position = [-0.5, 0.5]; + break; + case CharacterLayoutPosition.RightEdge: + position = [1.5, 0.5]; + break; + case CharacterLayoutPosition.BottomEdge: + position = [0.5, 1.5]; + break; + case CharacterLayoutPosition.BottomLeftEdge: + position = [0.3, 1.5]; + break; + case CharacterLayoutPosition.BottomRightEdge: + position = [0.7, 1.5]; + break; + default: + position = [0.5, 0.5]; + } + position[0] += offset / 2000; + return position; +} + +function move_speed(t: CharacterLayoutMoveSpeedType) { + switch (t) { + case CharacterLayoutMoveSpeedType.Fast: + return 300; + case CharacterLayoutMoveSpeedType.Normal: + return 500; + case CharacterLayoutMoveSpeedType.Slow: + return 700; + default: + return 300; + } +} + +export default async function action_layout( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.LayoutData[action.ReferenceIndex]; + controller.layers.telop.hide(500); + switch (action_detail.Type) { + case CharacterLayoutType.Motion: + { + log.log( + "Live2DController", + "CharacterLayout/Motion", + action, + action_detail + ); + const costume = controller.live2d_get_costume( + action_detail.Character2dId + )!; + // Step 1: Apply motions and expressions. + const motion = controller.apply_live2d_motion( + costume, + action_detail.MotionName, + action_detail.FacialName + ); + // (Same time) Move from current position to SideTo position or not move. + const to = side_to_position( + action_detail.SideTo, + action_detail.SideToOffsetX + ); + const move = controller.layers.live2d.move( + costume, + undefined, + to, + move_speed(action_detail.MoveSpeedType) + ); + + await move; + await motion; + } + break; + case CharacterLayoutType.Appear: + { + log.log( + "Live2DController", + "CharacterLayout/Appear", + action, + action_detail + ); + // update CostumeType + let costume = ""; + if (action_detail.CostumeType !== "") { + costume = controller.live2d_set_costume( + action_detail.Character2dId, + action_detail.CostumeType + ); + } else { + costume = controller.live2d_get_costume(action_detail.Character2dId)!; + } + // Step 1: Apply motions and expressions. (To get the finish pose.) + await controller.apply_live2d_motion( + costume, + action_detail.MotionName, + action_detail.FacialName + ); + // Step 2: Show. (after motion finished) + const show = controller.layers.live2d.show_model(costume, 200); + controller.live2d_set_appear(action_detail.Character2dId); + // (Same time) Move from SideFrom position to SideTo position or at SideFrom position. + const from = side_to_position( + action_detail.SideFrom, + action_detail.SideFromOffsetX + ); + const to = side_to_position( + action_detail.SideTo, + action_detail.SideToOffsetX + ); + let move; + if (from[0] === to[0] && from[1] === to[1]) { + controller.layers.live2d.set_position(costume, from); + } else { + move = controller.layers.live2d.move( + costume, + from, + to, + move_speed(action_detail.MoveSpeedType) + ); + } + // (Same time) Apply the same motions and expressions again. + controller.animate + .delay(10) + .then(() => + controller.apply_live2d_motion( + costume, + action_detail.MotionName, + action_detail.FacialName + ) + ); + + await show; + await move; + //await motion; + } + break; + case CharacterLayoutType.Clear: + { + log.log( + "Live2DController", + "CharacterLayout/Clear", + action, + action_detail + ); + const costume = controller.live2d_get_costume( + action_detail.Character2dId + )!; + // Step 1: Move from SideFrom position to SideTo position or not move. + const from = side_to_position( + action_detail.SideFrom, + action_detail.SideFromOffsetX + ); + const to = side_to_position( + action_detail.SideTo, + action_detail.SideToOffsetX + ); + if (!(from[0] === to[0] && from[1] === to[1])) { + await controller.layers.live2d.move( + costume, + from, + to, + move_speed(action_detail.MoveSpeedType) + ); + } + // Step 2: Wait for the model exist at least 2 seconds. + await controller.live2d_stay(action_detail.Character2dId, 2000); + // Step 3: Hide. + await controller.layers.live2d.hide_model(costume, 200); + } + break; + default: + log.warn( + "Live2DController", + `${SnippetAction[action.Action]}/${CharacterLayoutType[action_detail.Type]} not implemented!`, + action, + action_detail + ); + } +} diff --git a/src/utils/Live2DPlayer/action/character_motion.ts b/src/utils/Live2DPlayer/action/character_motion.ts new file mode 100644 index 00000000..f04ecb78 --- /dev/null +++ b/src/utils/Live2DPlayer/action/character_motion.ts @@ -0,0 +1,38 @@ +import type { Live2DController } from "../Live2DController"; +import type { Snippet } from "../../../types.d"; +import { SnippetAction } from "../../../types.d"; +import { CharacterLayoutType } from "../../../types.d"; +import { log } from "../log"; + +export default async function action_motion( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.LayoutData[action.ReferenceIndex]; + switch (action_detail.Type) { + case CharacterLayoutType.CharacterMotion: + { + log.log( + "Live2DController", + "CharacterMotion/CharacterMotion", + action, + action_detail + ); + // Step 1: Apply motions and expressions. + await controller.apply_live2d_motion( + controller.live2d_get_costume(action_detail.Character2dId)!, + action_detail.MotionName, + action_detail.FacialName + ); + } + break; + default: + log.warn( + "Live2DController", + `${SnippetAction[action.Action]}/${CharacterLayoutType[action_detail.Type]} not implemented!`, + action, + action_detail + ); + } +} diff --git a/src/utils/Live2DPlayer/action/index.ts b/src/utils/Live2DPlayer/action/index.ts new file mode 100644 index 00000000..ed021b90 --- /dev/null +++ b/src/utils/Live2DPlayer/action/index.ts @@ -0,0 +1,39 @@ +import type { Live2DController } from "../Live2DController"; +import type { Snippet } from "../../../types.d"; +import { SnippetAction } from "../../../types.d"; +import { log } from "../log"; + +import action_talk from "./talk"; +import action_sound from "./sound"; +import action_motion from "./character_motion"; +import action_layout from "./character_layout"; +import action_se from "./special_effect"; + +export default async function single_action( + controller: Live2DController, + action: Snippet +) { + switch (action.Action) { + case SnippetAction.SpecialEffect: + await action_se(controller, action); + break; + case SnippetAction.CharacterLayout: + await action_layout(controller, action); + break; + case SnippetAction.CharacterMotion: + await action_motion(controller, action); + break; + case SnippetAction.Talk: + await action_talk(controller, action); + break; + case SnippetAction.Sound: + await action_sound(controller, action); + break; + default: + log.warn( + "Live2DController", + `${SnippetAction[action.Action]} not implemented!`, + action + ); + } +} diff --git a/src/utils/Live2DPlayer/action/sound.ts b/src/utils/Live2DPlayer/action/sound.ts new file mode 100644 index 00000000..7b8ae576 --- /dev/null +++ b/src/utils/Live2DPlayer/action/sound.ts @@ -0,0 +1,69 @@ +import type { Live2DController } from "../Live2DController"; +import type { Snippet } from "../../../types.d"; +import { SoundPlayMode } from "../../../types.d"; +import { Live2DAssetType } from "../types.d"; +import { log } from "../log"; + +export default async function action_sound( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SoundData[action.ReferenceIndex]; + log.log("Live2DController", "Sound", action, action_detail); + if (action_detail.Bgm) { + if (action_detail.Bgm === "bgm00000") { + Howler.stop(); + } else { + const sound = controller.scenarioResource.find( + (s) => + s.identifer === action_detail.Bgm && + s.type === Live2DAssetType.BackgroundMusic + )?.data as Howl; + sound.loop(true); + controller.stop_sounds([Live2DAssetType.BackgroundMusic]); + sound.volume(0.8); + sound.play(); + } + } else if (action_detail.Se) { + const sound = controller.scenarioResource.find( + (s) => + s.identifer === action_detail.Se && + s.type === Live2DAssetType.SoundEffect + )?.data; + if (sound) { + switch (action_detail.PlayMode) { + case SoundPlayMode.Stop: + { + (sound as Howl).stop(); + } + break; + case SoundPlayMode.SpecialSePlay: + { + (sound as Howl).loop(true); + (sound as Howl).play(); + } + break; + case SoundPlayMode.CrossFade: + { + (sound as Howl).loop(false); + (sound as Howl).play(); + } + break; + case SoundPlayMode.Stack: + { + (sound as Howl).loop(false); + (sound as Howl).play(); + } + break; + default: + log.warn( + "Live2DController", + `Sound/SoundPlayMode:${action_detail.PlayMode} not implemented!`, + action + ); + } + } else + log.warn("Live2DController", `${action_detail.Se} not loaded, skip.`); + } +} diff --git a/src/utils/Live2DPlayer/action/special_effect/AmbientColorEvening.ts b/src/utils/Live2DPlayer/action/special_effect/AmbientColorEvening.ts new file mode 100644 index 00000000..5d772f86 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/AmbientColorEvening.ts @@ -0,0 +1,28 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; +import { ColorMatrixFilter } from "pixi.js"; + +export default async function AmbientColorEvening( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/AmbientColorEvening", + action, + action_detail + ); + controller.layers.live2d.remove_filter(); + controller.layers.live2d.add_color_filter( + [0.9, 0, 0, 0, 0], + [0, 0.9, 0, 0, 0], + [0, 0, 0.8, 0, 0], + [0, 0, 0, 1, 0] + ); + const filter = new ColorMatrixFilter(); + filter.saturate(-0.1); + controller.layers.live2d.add_filter(filter); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/AmbientColorNight.ts b/src/utils/Live2DPlayer/action/special_effect/AmbientColorNight.ts new file mode 100644 index 00000000..61742eaa --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/AmbientColorNight.ts @@ -0,0 +1,28 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; +import { ColorMatrixFilter } from "pixi.js"; + +export default async function AmbientColorNight( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/AmbientColorNight", + action, + action_detail + ); + controller.layers.live2d.remove_filter(); + controller.layers.live2d.add_color_filter( + [0.85, 0, 0, 0, 0], + [0, 0.85, 0, 0, 0], + [0, 0, 0.9, 0, 0], + [0, 0, 0, 1, 0] + ); + const filter = new ColorMatrixFilter(); + filter.saturate(-0.1); + controller.layers.live2d.add_filter(filter); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/AmbientColorNormal.ts b/src/utils/Live2DPlayer/action/special_effect/AmbientColorNormal.ts new file mode 100644 index 00000000..af065944 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/AmbientColorNormal.ts @@ -0,0 +1,18 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function AmbientColorNormal( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/AmbientColorNormal", + action, + action_detail + ); + controller.layers.live2d.remove_filter(); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/AttachCharacterShader.ts b/src/utils/Live2DPlayer/action/special_effect/AttachCharacterShader.ts new file mode 100644 index 00000000..3f67a96c --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/AttachCharacterShader.ts @@ -0,0 +1,54 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { + SpecialEffectType, + SeAttachCharacterShaderType, + SnippetAction, +} from "../../../../types.d"; +import { log } from "../../log"; + +export default async function AttachCharacterShader( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/AttachCharacterShader", + action, + action_detail + ); + switch (action_detail.StringVal) { + case SeAttachCharacterShaderType.Hologram: + { + controller.layers.live2d.add_effect( + controller.live2d_get_costume(action_detail.IntVal)!, + "hologram" + ); + controller.current_costume + .find((c) => c.cid === action_detail.IntVal)! + .animations.push("hologram"); + } + break; + case SeAttachCharacterShaderType.None: + case SeAttachCharacterShaderType.Empty: + { + controller.layers.live2d.remove_effect( + controller.live2d_get_costume(action_detail.IntVal)!, + "hologram" + ); + controller.current_costume.find( + (c) => c.cid === action_detail.IntVal + )!.animations = []; + } + break; + default: + log.warn( + "Live2DController", + `${SnippetAction[action.Action]}/${SpecialEffectType[action_detail.EffectType]}/${(SeAttachCharacterShaderType as any)[action_detail.StringVal]} not implemented!`, + action, + action_detail + ); + } +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackIn.ts b/src/utils/Live2DPlayer/action/special_effect/BlackIn.ts new file mode 100644 index 00000000..65248c32 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackIn.ts @@ -0,0 +1,15 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackIn( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log("Live2DController", "SpecialEffect/BlackIn", action, action_detail); + controller.layers.fullcolor.draw(0x000000); + controller.layers.fullscreen_text.hide(100); + await controller.layers.fullcolor.hide(action_detail.Duration * 1000, false); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackOut.ts b/src/utils/Live2DPlayer/action/special_effect/BlackOut.ts new file mode 100644 index 00000000..fb356206 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackOut.ts @@ -0,0 +1,15 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackOut( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log("Live2DController", "SpecialEffect/BlackOut", action, action_detail); + controller.layers.dialog.hide(100); + controller.layers.fullcolor.draw(0x000000); + await controller.layers.fullcolor.show(action_detail.Duration * 1000, true); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeInBottom.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInBottom.ts new file mode 100644 index 00000000..867b9355 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInBottom.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeInBottom( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeInBottom", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + false, + "top", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeInLeft.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInLeft.ts new file mode 100644 index 00000000..fade3437 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInLeft.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeInLeft( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeInLeft", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + false, + "right", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeInRight.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInRight.ts new file mode 100644 index 00000000..6b125bec --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInRight.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeInRight( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeInRight", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + false, + "left", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeInTop.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInTop.ts new file mode 100644 index 00000000..32e91e6e --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeInTop.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeInTop( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeInTop", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + false, + "bottom", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutBottom.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutBottom.ts new file mode 100644 index 00000000..173443c9 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutBottom.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeOutBottom( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeOutBottom", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + true, + "bottom", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutLeft.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutLeft.ts new file mode 100644 index 00000000..0286cfb2 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutLeft.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeOutLeft( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeOutLeft", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + true, + "left", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutRight.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutRight.ts new file mode 100644 index 00000000..391173a8 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutRight.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeOutRight( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeOutRight", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + true, + "right", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutTop.ts b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutTop.ts new file mode 100644 index 00000000..96c8b500 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/BlackWipeOutTop.ts @@ -0,0 +1,23 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function BlackWipeOutTop( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/BlackWipeOutTop", + action, + action_detail + ); + controller.layers.wipe.draw(); + await controller.layers.wipe.animate( + true, + "top", + action_detail.Duration * 1000 + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/ChangeBackground.ts b/src/utils/Live2DPlayer/action/special_effect/ChangeBackground.ts new file mode 100644 index 00000000..52481e0a --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/ChangeBackground.ts @@ -0,0 +1,24 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { Live2DAssetType } from "../../types.d"; +import { log } from "../../log"; + +export default async function ChangeBackground( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/ChangeBackground", + action, + action_detail + ); + const data = controller.scenarioResource.find( + (s) => + s.identifer === action_detail.StringValSub && + s.type === Live2DAssetType.BackgroundImage + )?.data as HTMLImageElement; + controller.layers.background.draw(data); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/FlashbackIn.ts b/src/utils/Live2DPlayer/action/special_effect/FlashbackIn.ts new file mode 100644 index 00000000..1c946849 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/FlashbackIn.ts @@ -0,0 +1,19 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function FlashbackIn( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/FlashbackIn", + action, + action_detail + ); + controller.layers.flashback.draw(); + await controller.layers.flashback.show(100, true); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/FlashbackOut.ts b/src/utils/Live2DPlayer/action/special_effect/FlashbackOut.ts new file mode 100644 index 00000000..d7d7da16 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/FlashbackOut.ts @@ -0,0 +1,18 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function FlashbackOut( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/FlashbackOut", + action, + action_detail + ); + await controller.layers.flashback.hide(100, true); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts b/src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts new file mode 100644 index 00000000..76367959 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts @@ -0,0 +1,33 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { Live2DAssetType } from "../../types.d"; +import { log } from "../../log"; + +export default async function FlashbackIn( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/FullScreenText", + action, + action_detail + ); + const sound = controller.scenarioResource.find( + (s) => + s.identifer === action_detail.StringValSub && + s.type === Live2DAssetType.Talk + ); + if (sound) { + controller.stop_sounds([Live2DAssetType.Talk]); + (sound.data as Howl).play(); + } else + log.warn( + "Live2DController", + `${action_detail.StringValSub} not loaded, skip.` + ); + controller.layers.fullscreen_text.show(500); + await controller.layers.fullscreen_text.animate(action_detail.StringVal); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/FullScreenTextHide.ts b/src/utils/Live2DPlayer/action/special_effect/FullScreenTextHide.ts new file mode 100644 index 00000000..52457fc9 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/FullScreenTextHide.ts @@ -0,0 +1,26 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function FullScreenTextHide( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/FullScreenTextHide", + action, + action_detail + ); + controller.layers.fullscreen_text_bg.draw(0x000000); + controller.layers.fullscreen_text_bg.hide( + action_detail.Duration * 1000, + true + ); + await controller.layers.fullscreen_text.hide( + action_detail.Duration * 1000, + true + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/FullScreenTextShow.ts b/src/utils/Live2DPlayer/action/special_effect/FullScreenTextShow.ts new file mode 100644 index 00000000..c83e0848 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/FullScreenTextShow.ts @@ -0,0 +1,22 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function FullScreenTextShow( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/FullScreenTextShow", + action, + action_detail + ); + controller.layers.fullscreen_text_bg.draw(0x000000); + await controller.layers.fullscreen_text_bg.show( + action_detail.Duration * 1000, + true + ); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/PlayScenarioEffect.ts b/src/utils/Live2DPlayer/action/special_effect/PlayScenarioEffect.ts new file mode 100644 index 00000000..d10ce8a7 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/PlayScenarioEffect.ts @@ -0,0 +1,18 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function PlayScenarioEffect( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/PlayScenarioEffect", + action, + action_detail + ); + controller.layers.scene_effect.draw(action_detail.StringVal); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/SekaiIn.ts b/src/utils/Live2DPlayer/action/special_effect/SekaiIn.ts new file mode 100644 index 00000000..3662705a --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/SekaiIn.ts @@ -0,0 +1,14 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function FlashbackIn( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log("Live2DController", "SpecialEffect/SekaiIn", action, action_detail); + controller.layers.fullcolor.hide(action_detail.Duration * 1000); + await controller.layers.sekai.draw(false, action_detail.Duration * 1000); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/SekaiOut.ts b/src/utils/Live2DPlayer/action/special_effect/SekaiOut.ts new file mode 100644 index 00000000..787c2dcf --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/SekaiOut.ts @@ -0,0 +1,15 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function SekaiOut( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log("Live2DController", "SpecialEffect/SekaiOut", action, action_detail); + controller.layers.fullcolor.draw(0xffffff); + controller.layers.fullcolor.show(action_detail.Duration * 1000, true); + await controller.layers.sekai.draw(true, action_detail.Duration * 1000); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/ShakeScreen.ts b/src/utils/Live2DPlayer/action/special_effect/ShakeScreen.ts new file mode 100644 index 00000000..680d682a --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/ShakeScreen.ts @@ -0,0 +1,29 @@ +import type { Live2DController } from "../../Live2DController"; +import { Curve } from "../../animation/Curve"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function ShakeScreen( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/ShakeScreen", + action, + action_detail + ); + const time_ms = action_detail.Duration * 1000; + const freq = 30; + const amp = 0.01 * controller.stage_size[1]; + const curve_x = new Curve() + .wiggle(Math.floor((time_ms / 1000) * freq)) + .map_range(-amp, amp); + const curve_y = new Curve() + .wiggle(Math.floor((time_ms / 1000) * freq)) + .map_range(-amp, amp); + controller.layers.background.shake(curve_x, curve_y, time_ms); + controller.layers.live2d.shake(curve_x, curve_y, time_ms); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/ShakeWindow.ts b/src/utils/Live2DPlayer/action/special_effect/ShakeWindow.ts new file mode 100644 index 00000000..18a69d6d --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/ShakeWindow.ts @@ -0,0 +1,28 @@ +import type { Live2DController } from "../../Live2DController"; +import { Curve } from "../../animation/Curve"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function ShakeWindow( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/ShakeWindow", + action, + action_detail + ); + const time_ms = action_detail.Duration * 1000; + const freq = 30; + const amp = 0.01 * controller.stage_size[1]; + const curve_x = new Curve() + .wiggle(Math.floor((time_ms / 1000) * freq)) + .map_range(-amp, amp); + const curve_y = new Curve() + .wiggle(Math.floor((time_ms / 1000) * freq)) + .map_range(-amp, amp); + controller.layers.dialog.shake(curve_x, curve_y, time_ms); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/StopScenarioEffect.ts b/src/utils/Live2DPlayer/action/special_effect/StopScenarioEffect.ts new file mode 100644 index 00000000..0fbde27b --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/StopScenarioEffect.ts @@ -0,0 +1,18 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function StopScenarioEffect( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/StopScenarioEffect", + action, + action_detail + ); + controller.layers.scene_effect.remove(action_detail.StringVal); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/StopShakeScreen.ts b/src/utils/Live2DPlayer/action/special_effect/StopShakeScreen.ts new file mode 100644 index 00000000..36399bf9 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/StopShakeScreen.ts @@ -0,0 +1,19 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function StopShakeScreen( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/StopShakeScreen", + action, + action_detail + ); + controller.layers.background.stop_shake(); + controller.layers.live2d.stop_shake(); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/StopShakeWindow.ts b/src/utils/Live2DPlayer/action/special_effect/StopShakeWindow.ts new file mode 100644 index 00000000..20d6ed1b --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/StopShakeWindow.ts @@ -0,0 +1,18 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function StopShakeWindow( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log( + "Live2DController", + "SpecialEffect/StopShakeWindow", + action, + action_detail + ); + controller.layers.dialog.stop_shake(); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/Telop.ts b/src/utils/Live2DPlayer/action/special_effect/Telop.ts new file mode 100644 index 00000000..d1318b78 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/Telop.ts @@ -0,0 +1,14 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function Telop( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log("Live2DController", "SpecialEffect/Telop", action, action_detail); + controller.layers.telop.draw(action_detail.StringVal); + await controller.layers.telop.show(300, true); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/WhiteIn.ts b/src/utils/Live2DPlayer/action/special_effect/WhiteIn.ts new file mode 100644 index 00000000..56995a39 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/WhiteIn.ts @@ -0,0 +1,14 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function WhiteIn( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log("Live2DController", "SpecialEffect/WhiteIn", action, action_detail); + controller.layers.fullcolor.draw(0xffffff); + await controller.layers.fullcolor.hide(action_detail.Duration * 1000, true); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/WhiteOut.ts b/src/utils/Live2DPlayer/action/special_effect/WhiteOut.ts new file mode 100644 index 00000000..7bdd45b8 --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/WhiteOut.ts @@ -0,0 +1,15 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { log } from "../../log"; + +export default async function WhiteOut( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + log.log("Live2DController", "SpecialEffect/WhiteOut", action, action_detail); + controller.layers.dialog.hide(100); + controller.layers.fullcolor.draw(0xffffff); + await controller.layers.fullcolor.show(action_detail.Duration * 1000, true); +} diff --git a/src/utils/Live2DPlayer/action/special_effect/index.ts b/src/utils/Live2DPlayer/action/special_effect/index.ts new file mode 100644 index 00000000..c4e7f17c --- /dev/null +++ b/src/utils/Live2DPlayer/action/special_effect/index.ts @@ -0,0 +1,146 @@ +import type { Live2DController } from "../../Live2DController"; +import type { Snippet } from "../../../../types.d"; +import { SpecialEffectType, SnippetAction } from "../../../../types.d"; +import { log } from "../../log"; + +import ChangeBackground from "./ChangeBackground"; +import Telop from "./Telop"; +import WhiteIn from "./WhiteIn"; +import WhiteOut from "./WhiteOut"; +import BlackIn from "./BlackIn"; +import BlackOut from "./BlackOut"; +import FlashbackIn from "./FlashbackIn"; +import FlashbackOut from "./FlashbackOut"; +import AttachCharacterShader from "./AttachCharacterShader"; +import PlayScenarioEffect from "./PlayScenarioEffect"; +import StopScenarioEffect from "./StopScenarioEffect"; +import ShakeScreen from "./ShakeScreen"; +import ShakeWindow from "./ShakeWindow"; +import StopShakeScreen from "./StopShakeScreen"; +import StopShakeWindow from "./StopShakeWindow"; +import AmbientColorNormal from "./AmbientColorNormal"; +import AmbientColorEvening from "./AmbientColorEvening"; +import AmbientColorNight from "./AmbientColorNight"; +import BlackWipeInLeft from "./BlackWipeInLeft"; +import BlackWipeOutLeft from "./BlackWipeOutLeft"; +import BlackWipeInRight from "./BlackWipeInRight"; +import BlackWipeOutRight from "./BlackWipeOutRight"; +import BlackWipeInTop from "./BlackWipeInTop"; +import BlackWipeOutTop from "./BlackWipeOutTop"; +import BlackWipeInBottom from "./BlackWipeInBottom"; +import BlackWipeOutBottom from "./BlackWipeOutBottom"; +import SekaiIn from "./SekaiIn"; +import SekaiOut from "./SekaiOut"; +import FullScreenText from "./FullScreenText"; +import FullScreenTextShow from "./FullScreenTextShow"; +import FullScreenTextHide from "./FullScreenTextHide"; + +export default async function action_se( + controller: Live2DController, + action: Snippet +) { + const action_detail = + controller.scenarioData.SpecialEffectData[action.ReferenceIndex]; + switch (action_detail.EffectType) { + case SpecialEffectType.ChangeBackground: + await ChangeBackground(controller, action); + break; + case SpecialEffectType.Telop: + await Telop(controller, action); + break; + case SpecialEffectType.WhiteIn: + await WhiteIn(controller, action); + break; + case SpecialEffectType.WhiteOut: + await WhiteOut(controller, action); + break; + case SpecialEffectType.BlackIn: + await BlackIn(controller, action); + break; + case SpecialEffectType.BlackOut: + await BlackOut(controller, action); + break; + case SpecialEffectType.FlashbackIn: + await FlashbackIn(controller, action); + break; + case SpecialEffectType.FlashbackOut: + await FlashbackOut(controller, action); + break; + case SpecialEffectType.AttachCharacterShader: + await AttachCharacterShader(controller, action); + break; + case SpecialEffectType.PlayScenarioEffect: + await PlayScenarioEffect(controller, action); + break; + case SpecialEffectType.StopScenarioEffect: + await StopScenarioEffect(controller, action); + break; + case SpecialEffectType.ShakeScreen: + await ShakeScreen(controller, action); + break; + case SpecialEffectType.ShakeWindow: + await ShakeWindow(controller, action); + break; + case SpecialEffectType.StopShakeScreen: + await StopShakeScreen(controller, action); + break; + case SpecialEffectType.StopShakeWindow: + await StopShakeWindow(controller, action); + break; + case SpecialEffectType.AmbientColorNormal: + await AmbientColorNormal(controller, action); + break; + case SpecialEffectType.AmbientColorEvening: + await AmbientColorEvening(controller, action); + break; + case SpecialEffectType.AmbientColorNight: + await AmbientColorNight(controller, action); + break; + case SpecialEffectType.BlackWipeInLeft: + await BlackWipeInLeft(controller, action); + break; + case SpecialEffectType.BlackWipeOutLeft: + await BlackWipeOutLeft(controller, action); + break; + case SpecialEffectType.BlackWipeInRight: + await BlackWipeInRight(controller, action); + break; + case SpecialEffectType.BlackWipeOutRight: + await BlackWipeOutRight(controller, action); + break; + case SpecialEffectType.BlackWipeInTop: + await BlackWipeInTop(controller, action); + break; + case SpecialEffectType.BlackWipeOutTop: + await BlackWipeOutTop(controller, action); + break; + case SpecialEffectType.BlackWipeInBottom: + await BlackWipeInBottom(controller, action); + break; + case SpecialEffectType.BlackWipeOutBottom: + await BlackWipeOutBottom(controller, action); + break; + case SpecialEffectType.SekaiIn: + await SekaiIn(controller, action); + break; + case SpecialEffectType.SekaiOut: + await SekaiOut(controller, action); + break; + case SpecialEffectType.FullScreenText: + await FullScreenText(controller, action); + break; + case SpecialEffectType.FullScreenTextShow: + await FullScreenTextShow(controller, action); + break; + case SpecialEffectType.FullScreenTextHide: + await FullScreenTextHide(controller, action); + break; + default: + log.warn( + "Live2DController", + `${SnippetAction[action.Action]}/${SpecialEffectType[action_detail.EffectType]} not implemented!`, + action, + action_detail + ); + } +} diff --git a/src/utils/Live2DPlayer/action/talk.ts b/src/utils/Live2DPlayer/action/talk.ts new file mode 100644 index 00000000..e823263a --- /dev/null +++ b/src/utils/Live2DPlayer/action/talk.ts @@ -0,0 +1,54 @@ +import type { Live2DController } from "../Live2DController"; +import type { Snippet } from "../../../types.d"; +import { Live2DAssetType } from "../types.d"; +import { log } from "../log"; + +export default async function action_talk( + controller: Live2DController, + action: Snippet +) { + const action_detail = controller.scenarioData.TalkData[action.ReferenceIndex]; + log.log("Live2DController", "Talk", action, action_detail); + //clear + await controller.layers.telop.hide(200); + // show dialog + const dialog = controller.layers.dialog.animate( + action_detail.WindowDisplayName, + action_detail.Body + ); + await controller.layers.dialog.show(200); + // motion + const motion = action_detail.Motions.map((m) => { + controller.apply_live2d_motion( + controller.live2d_get_costume(m.Character2dId)!, + m.MotionName, + m.FacialName + ); + }); + // sound + if (action_detail.Voices.length > 0) { + controller.stop_sounds([Live2DAssetType.Talk]); + const sound = controller.scenarioResource.find( + (s) => + s.identifer === action_detail.Voices[0].VoiceId && + s.type === Live2DAssetType.Talk + ); + if (sound) { + const costume = controller.live2d_get_costume( + action_detail.TalkCharacters[0].Character2dId + ); + if (costume) { + controller.layers.live2d.speak(costume, sound.url); + } else { + (sound.data as Howl).play(); + } + } else + log.warn( + "Live2DController", + `${action_detail.Voices[0].VoiceId} not loaded, skip.` + ); + } + // wait motion and text animation + await Promise.all(motion); + await dialog; +} From 9606ace15d680b410cdaea68acecdd1759039b6b Mon Sep 17 00:00:00 2001 From: K-bai Date: Thu, 23 Jan 2025 10:44:28 +0800 Subject: [PATCH 06/10] feat(live2d): add volume and animation controller --- .../StoryReaderLive2DCanvas.tsx | 146 +++++++++++++++++- .../StoryReaderLive2DContent.tsx | 21 +-- src/utils/Live2DPlayer/Live2DController.ts | 50 +++++- src/utils/Live2DPlayer/README.md | 5 +- src/utils/Live2DPlayer/action/sound.ts | 22 +-- .../action/special_effect/FullScreenText.ts | 10 +- src/utils/Live2DPlayer/action/talk.ts | 25 ++- src/utils/Live2DPlayer/layer/Live2D.ts | 3 +- src/utils/Live2DPlayer/load.ts | 4 +- src/utils/Live2DPlayer/types.d.ts | 6 +- 10 files changed, 236 insertions(+), 56 deletions(-) diff --git a/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx b/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx index 2ba79a7e..1772ee40 100644 --- a/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx +++ b/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx @@ -7,7 +7,16 @@ import React, { useEffect, } from "react"; import { useTranslation } from "react-i18next"; -import { CircularProgress, Typography, Stack } from "@mui/material"; +import { + CircularProgress, + Typography, + Stack, + Grid, + Slider, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import { VolumeDown, VolumeUp } from "@mui/icons-material"; import { Stage, useApp } from "@pixi/react"; @@ -69,8 +78,7 @@ StoryReaderLive2DStage.displayName = "StoryReaderLive2DStage"; const StoryReaderLive2DCanvas: React.FC<{ controllerData: ILive2DControllerData; - autoplay: boolean; -}> = ({ controllerData, autoplay }) => { +}> = ({ controllerData }) => { const { t } = useTranslation(); const wrap = useRef(null); @@ -85,6 +93,11 @@ const StoryReaderLive2DCanvas: React.FC<{ const [autoplayWaiting, setAutoplayWaiting] = useState(false); const [canClick, setCanClick] = useState(true); const [loadStatus, setLoadStatus] = useState(LoadStatus.Ready); + const [bgmVolume, setBgmVolume] = useState(30); + const [seVolume, setSeVolume] = useState(80); + const [voiceVolume, setVoiceVolume] = useState(80); + const [textAnimation, setTextAnimation] = useState(true); + const [autoplay, setAutoplay] = useState(false); // change canvas size useLayoutEffect(() => { @@ -190,6 +203,14 @@ const StoryReaderLive2DCanvas: React.FC<{ const handleModelLoad = (status: LoadStatus) => { setLoadStatus(status); if (status === LoadStatus.Loaded) { + if (stage.current) { + stage.current.controller.settings.text_animation = textAnimation; + stage.current.controller.set_volume({ + bgm_volume: bgmVolume / 100, + se_volume: seVolume / 100, + voice_volume: voiceVolume / 100, + }); + } nextStep(); } }; @@ -208,6 +229,41 @@ const StoryReaderLive2DCanvas: React.FC<{ } }; + const handleBgmVolumeChange = ( + event: Event, + newBgmVolume: number | number[] + ) => { + const volume = newBgmVolume as number; + setBgmVolume(volume); + stage.current?.controller.set_volume({ bgm_volume: volume / 100 }); + }; + const handleVoiceVolumeChange = ( + event: Event, + newVoiceVolume: number | number[] + ) => { + const volume = newVoiceVolume as number; + setVoiceVolume(volume); + stage.current?.controller.set_volume({ voice_volume: volume / 100 }); + }; + const handleSeVolumeChange = ( + event: Event, + newSeVolume: number | number[] + ) => { + const volume = newSeVolume as number; + setSeVolume(volume); + stage.current?.controller.set_volume({ se_volume: volume / 100 }); + }; + const handleTextAnimationChange = ( + event: React.ChangeEvent + ) => { + setTextAnimation(event.target.checked); + if (stage.current) + stage.current.controller.settings.text_animation = event.target.checked; + }; + const handleAutoplayChange = (event: React.ChangeEvent) => { + setAutoplay(event.target.checked); + }; + return ( + {controllerData && ( + + + {t("story_reader_live2d:settings.heading")} + + + + + + } + label={t("story_reader_live2d:auto_play")} + /> + + + + + + } + label={t("story_reader_live2d:settings.text_animation")} + /> + + + + + + {t("story_reader_live2d:settings.bgm_volume")} + + + + + + + + + + + + {t("story_reader_live2d:settings.voice_volume")} + + + + + + + + + + + + {t("story_reader_live2d:settings.se_volume")} + + + + + + + + + + + )} { //DEBUG /* diff --git a/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx b/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx index 5edba078..d8659ab3 100644 --- a/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx +++ b/src/pages/storyreader-live2d/StoryReaderLive2DContent.tsx @@ -17,14 +17,7 @@ import { import { IScenarioData, ServerRegion } from "../../types.d"; import ContainerContent from "../../components/styled/ContainerContent"; -import { - Stack, - Button, - Typography, - LinearProgress, - FormControlLabel, - Checkbox, -} from "@mui/material"; +import { Stack, Button, Typography, LinearProgress } from "@mui/material"; import StoryReaderLive2DCanvas from "./StoryReaderLive2DCanvas"; import { useAlertSnackbar } from "../../utils"; @@ -42,7 +35,6 @@ const StoryReaderLive2DContent: React.FC<{ const [loadStatus, setLoadStatus] = useState(LoadStatus.Ready); const [loadProgress, setLoadProgress] = useState(0); const [progressText, setProgressText] = useState(""); - const [autoplay, setAutoplay] = useState(false); const { showError } = useAlertSnackbar(); @@ -148,10 +140,6 @@ const StoryReaderLive2DContent: React.FC<{ } } - const handleAutoplayChange = (event: React.ChangeEvent) => { - setAutoplay(event.target.checked); - }; - return ( {t("story_reader_live2d:toggle_full_screen")} - - } - label={t("story_reader_live2d:auto_play")} - /> {loadStatus === LoadStatus.Loading && ( @@ -197,7 +179,6 @@ const StoryReaderLive2DContent: React.FC<{
)} diff --git a/src/utils/Live2DPlayer/Live2DController.ts b/src/utils/Live2DPlayer/Live2DController.ts index d931ae5c..2ccc562d 100644 --- a/src/utils/Live2DPlayer/Live2DController.ts +++ b/src/utils/Live2DPlayer/Live2DController.ts @@ -11,6 +11,7 @@ import { import { Live2DAssetType, + Live2DAssetTypeSound, Live2DAssetTypeUI, ILive2DCachedAsset, ILive2DModelDataCollection, @@ -29,9 +30,15 @@ export class Live2DController extends Live2DPlayer { costume: string; appear_time: number; animations: string[]; - }[]; + }[] = []; - public step: number; + step = 0; + settings = { + bgm_volume: 0.3, + voice_volume: 0.8, + se_volume: 0.8, + text_animation: true, + }; constructor( app: Application, @@ -41,21 +48,17 @@ export class Live2DController extends Live2DPlayer { super( app, stageSize, - data.scenarioResource.filter((a) => - Live2DAssetTypeUI.includes(a.type as any) - ) + data.scenarioResource.filter((a) => Live2DAssetTypeUI.includes(a.type)) ); this.scenarioData = data.scenarioData; this.scenarioResource = data.scenarioResource; this.modelData = data.modelData; - this.current_costume = []; - this.step = 0; - this.model_queue = this.create_model_queue(); log.log("Live2DController", "init."); log.log("Live2DController", this.scenarioData); log.log("Live2DController", this.scenarioResource); log.log("Live2DController", this.modelData); + this.model_queue = this.create_model_queue(); } /** @@ -340,6 +343,37 @@ export class Live2DController extends Live2DPlayer { await this.animate.delay(min_time_ms - duration); } }; + set_volume = (volume: { + voice_volume?: number; + bgm_volume?: number; + se_volume?: number; + }) => { + Object.assign(this.settings, volume); + if (volume.bgm_volume) this.settings.bgm_volume *= 0.5; // bgm too load + const s_list = this.scenarioResource.filter( + (sound) => + Live2DAssetTypeSound.includes(sound.type) && + (sound.data as Howl).playing() + ); + if (volume.voice_volume) + s_list + .filter((sound) => sound.type === Live2DAssetType.Talk) + .forEach((sound) => { + (sound.data as Howl).volume(this.settings.voice_volume); + }); + if (volume.bgm_volume) + s_list + .filter((sound) => sound.type === Live2DAssetType.BackgroundMusic) + .forEach((sound) => { + (sound.data as Howl).volume(this.settings.bgm_volume); + }); + if (volume.se_volume) + s_list + .filter((sound) => sound.type === Live2DAssetType.SoundEffect) + .forEach((sound) => { + (sound.data as Howl).volume(this.settings.se_volume); + }); + }; stop_sounds = (sound_types: Live2DAssetType[]) => { this.scenarioResource .filter( diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index d141d917..c30b774f 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -1,6 +1,5 @@ # TODO -- volume settings - bgm fade in and out - VoiceId more than one - more SpecialEffectType.PlayScenarioEffect @@ -84,5 +83,7 @@ - commit 9eb807e1e0be21c0e1e2a2d6794714511e2a73a6 - feat(live2d): add support for none 16:9 screen in fullscreen mode - fix(live2d): fullscreen text background not work properly +- commit f421842cfbbd4f432ffca40141f49a4c1fd8c6cf + - refactor(live2d): split live2d player action into multiple files - commit this - - refactor(live2d): split live2d player action into multiple files \ No newline at end of file + - feat(live2d): add volume and animation controller diff --git a/src/utils/Live2DPlayer/action/sound.ts b/src/utils/Live2DPlayer/action/sound.ts index 7b8ae576..dbfe8654 100644 --- a/src/utils/Live2DPlayer/action/sound.ts +++ b/src/utils/Live2DPlayer/action/sound.ts @@ -22,7 +22,7 @@ export default async function action_sound( )?.data as Howl; sound.loop(true); controller.stop_sounds([Live2DAssetType.BackgroundMusic]); - sound.volume(0.8); + sound.volume(controller.settings.bgm_volume * action_detail.Volume); sound.play(); } } else if (action_detail.Se) { @@ -30,30 +30,34 @@ export default async function action_sound( (s) => s.identifer === action_detail.Se && s.type === Live2DAssetType.SoundEffect - )?.data; + )?.data as Howl; + const volume = controller.settings.se_volume * action_detail.Volume; if (sound) { switch (action_detail.PlayMode) { case SoundPlayMode.Stop: { - (sound as Howl).stop(); + sound.stop(); } break; case SoundPlayMode.SpecialSePlay: { - (sound as Howl).loop(true); - (sound as Howl).play(); + sound.loop(true); + sound.volume(volume); + sound.play(); } break; case SoundPlayMode.CrossFade: { - (sound as Howl).loop(false); - (sound as Howl).play(); + sound.loop(false); + sound.volume(volume); + sound.play(); } break; case SoundPlayMode.Stack: { - (sound as Howl).loop(false); - (sound as Howl).play(); + sound.loop(false); + sound.volume(volume); + sound.play(); } break; default: diff --git a/src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts b/src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts index 76367959..8bdd8ebf 100644 --- a/src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts +++ b/src/utils/Live2DPlayer/action/special_effect/FullScreenText.ts @@ -22,12 +22,18 @@ export default async function FlashbackIn( ); if (sound) { controller.stop_sounds([Live2DAssetType.Talk]); - (sound.data as Howl).play(); + const inst = sound.data as Howl; + inst.volume(controller.settings.voice_volume); + inst.play(); } else log.warn( "Live2DController", `${action_detail.StringValSub} not loaded, skip.` ); controller.layers.fullscreen_text.show(500); - await controller.layers.fullscreen_text.animate(action_detail.StringVal); + if (controller.settings.text_animation) { + await controller.layers.fullscreen_text.animate(action_detail.StringVal); + } else { + await controller.layers.fullscreen_text.draw(action_detail.StringVal); + } } diff --git a/src/utils/Live2DPlayer/action/talk.ts b/src/utils/Live2DPlayer/action/talk.ts index e823263a..5f3d1123 100644 --- a/src/utils/Live2DPlayer/action/talk.ts +++ b/src/utils/Live2DPlayer/action/talk.ts @@ -12,10 +12,19 @@ export default async function action_talk( //clear await controller.layers.telop.hide(200); // show dialog - const dialog = controller.layers.dialog.animate( - action_detail.WindowDisplayName, - action_detail.Body - ); + let dialog; + if (controller.settings.text_animation) { + dialog = controller.layers.dialog.animate( + action_detail.WindowDisplayName, + action_detail.Body + ); + } else { + controller.layers.dialog.draw( + action_detail.WindowDisplayName, + action_detail.Body + ); + } + await controller.layers.dialog.show(200); // motion const motion = action_detail.Motions.map((m) => { @@ -37,10 +46,14 @@ export default async function action_talk( const costume = controller.live2d_get_costume( action_detail.TalkCharacters[0].Character2dId ); + const volume = + action_detail.Voices[0].Volume * controller.settings.voice_volume; if (costume) { - controller.layers.live2d.speak(costume, sound.url); + controller.layers.live2d.speak(costume, sound.url, volume); } else { - (sound.data as Howl).play(); + const inst = sound.data as Howl; + inst.volume(volume); + inst.play(); } } else log.warn( diff --git a/src/utils/Live2DPlayer/layer/Live2D.ts b/src/utils/Live2DPlayer/layer/Live2D.ts index 0b79a7b7..2ecbcd33 100644 --- a/src/utils/Live2DPlayer/layer/Live2D.ts +++ b/src/utils/Live2DPlayer/layer/Live2D.ts @@ -201,10 +201,11 @@ export default class Live2D extends BaseLayer { } }; - speak = (costume: string, url: string) => { + speak = (costume: string, url: string, volume: number) => { const model = this.find(costume); if (model) { model.speak(url, { + volume, resetExpression: false, crossOrigin: "anonymous", onFinish: () => { diff --git a/src/utils/Live2DPlayer/load.ts b/src/utils/Live2DPlayer/load.ts index 4f04ca1e..b7f3f0f7 100644 --- a/src/utils/Live2DPlayer/load.ts +++ b/src/utils/Live2DPlayer/load.ts @@ -107,13 +107,13 @@ export async function preloadMedia( await queue.wait(); await queue.add( new Promise((resolve, reject) => { - if (Live2DAssetTypeSound.includes(url.type as any)) { + if (Live2DAssetTypeSound.includes(url.type)) { preloadSound(url.url) .then((data) => { resolve({ ...url, data }); }) .catch(reject); - } else if (Live2DAssetTypeImage.includes(url.type as any)) { + } else if (Live2DAssetTypeImage.includes(url.type)) { preloadImage(url.url) .then((data) => { resolve({ ...url, data }); diff --git a/src/utils/Live2DPlayer/types.d.ts b/src/utils/Live2DPlayer/types.d.ts index cecb57e5..35d4580e 100644 --- a/src/utils/Live2DPlayer/types.d.ts +++ b/src/utils/Live2DPlayer/types.d.ts @@ -18,19 +18,19 @@ export const Live2DAssetTypeImage = [ Live2DAssetType.UI, Live2DAssetType.UISheet, Live2DAssetType.BackgroundImage, -] as const; +]; export const Live2DAssetTypeSound = [ Live2DAssetType.SoundEffect, Live2DAssetType.BackgroundMusic, Live2DAssetType.Talk, -] as const; +]; export const Live2DAssetTypeUI = [ Live2DAssetType.UI, Live2DAssetType.UISheet, Live2DAssetType.UIVideo, -] as const; +]; export interface ILive2DAssetUrl { identifer: string; From 7b64edb844934415d139d4cee38a44a35da79632 Mon Sep 17 00:00:00 2001 From: K-bai Date: Thu, 23 Jan 2025 11:28:23 +0800 Subject: [PATCH 07/10] fix(live2d): areatalk not load properly --- .../StoryReaderLive2DCanvas.tsx | 10 ++--- src/story-scenerio.d.ts | 18 ++++++++- src/utils/Live2DPlayer/Live2DController.ts | 5 ++- src/utils/Live2DPlayer/README.md | 5 ++- src/utils/storyLoader.ts | 40 ++++++++++++++++++- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx b/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx index 1772ee40..445219fb 100644 --- a/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx +++ b/src/pages/storyreader-live2d/StoryReaderLive2DCanvas.tsx @@ -193,11 +193,11 @@ const StoryReaderLive2DCanvas: React.FC<{ e.preventDefault(); if (loadStatus === LoadStatus.Loaded && canClick) { nextStep(); + setCanClick(false); + setTimeout(() => { + setCanClick(true); + }, 300); } - setCanClick(false); - setTimeout(() => { - setCanClick(true); - }, 300); }; const handleModelLoad = (status: LoadStatus) => { @@ -216,7 +216,7 @@ const StoryReaderLive2DCanvas: React.FC<{ }; const nextStep = () => { - if (!playing && !autoplayWaiting) { + if (!playing && !autoplayWaiting && scenarioStep !== -1) { setPlaying(true); stage.current?.controller .step_until_checkpoint(scenarioStep) diff --git a/src/story-scenerio.d.ts b/src/story-scenerio.d.ts index 10ec42cd..d0631967 100644 --- a/src/story-scenerio.d.ts +++ b/src/story-scenerio.d.ts @@ -219,6 +219,22 @@ export interface LayoutData { MoveSpeedType: CharacterLayoutMoveSpeedType; } +export interface FirstLayoutData { + Character2dId: number; + CostumeType: string; + MotionName: string; + FacialName: string; + /** + * @see {@link CharacterLayoutMoveSpeedType} + */ + OffsetX: number; + /** + * Define live2d model position. + * @see {@link CharacterLayoutPosition} + */ + PositionSide: number; +} + export enum SpecialEffectType { None = 0, BlackIn = 1, @@ -544,7 +560,7 @@ export interface ScenarioSnippetCharacterLayoutMode { export interface IScenarioData { ScenarioId: string; AppearCharacters: AppearCharacter[]; - FirstLayout: LayoutData[]; + FirstLayout: FirstLayoutData[]; FirstBgm: string; FirstBackground: string; FirstCharacterLayoutMode: CharacterLayoutMode; diff --git a/src/utils/Live2DPlayer/Live2DController.ts b/src/utils/Live2DPlayer/Live2DController.ts index 2ccc562d..8dcee2c3 100644 --- a/src/utils/Live2DPlayer/Live2DController.ts +++ b/src/utils/Live2DPlayer/Live2DController.ts @@ -54,11 +54,11 @@ export class Live2DController extends Live2DPlayer { this.scenarioResource = data.scenarioResource; this.modelData = data.modelData; + this.model_queue = this.create_model_queue(); log.log("Live2DController", "init."); log.log("Live2DController", this.scenarioData); log.log("Live2DController", this.scenarioResource); log.log("Live2DController", this.modelData); - this.model_queue = this.create_model_queue(); } /** @@ -240,7 +240,8 @@ export class Live2DController extends Live2DPlayer { } } - return current; + // if reach end, return -1 + return is_end(current) ? -1 : current; }; apply_action = async (step: number, delay_offset_ms = 0) => { const action = this.scenarioData.Snippets[step]; diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index c30b774f..ef432652 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -85,5 +85,8 @@ - fix(live2d): fullscreen text background not work properly - commit f421842cfbbd4f432ffca40141f49a4c1fd8c6cf - refactor(live2d): split live2d player action into multiple files -- commit this +- commit 6da200623f1aa45b47536f3bf560c604710f8aa5 - feat(live2d): add volume and animation controller +- commit this + - fix(live2d): areatalk not load properly + - fix(live2d): throw error when player finished diff --git a/src/utils/storyLoader.ts b/src/utils/storyLoader.ts index bcb4204f..1de40243 100644 --- a/src/utils/storyLoader.ts +++ b/src/utils/storyLoader.ts @@ -22,6 +22,10 @@ import { Snippet, SpecialEffectData, SoundData, + LayoutData, + CharacterLayoutType, + CharacterLayoutDepthType, + CharacterLayoutMoveSpeedType, } from "../types.d"; import { ILive2DAssetUrl, Live2DAssetType } from "./Live2DPlayer/types.d"; import { useCharaName, useAssetI18n } from "./i18n"; @@ -444,8 +448,15 @@ export async function getProcessedScenarioDataForLive2D( responseType: "json", } ); - const { Snippets, SpecialEffectData, SoundData, FirstBgm, FirstBackground } = - data; + const { + Snippets, + SpecialEffectData, + SoundData, + FirstBgm, + FirstBackground, + FirstLayout, + LayoutData, + } = data; if (FirstBackground) { const bgSnippet: Snippet = { @@ -482,6 +493,31 @@ export async function getProcessedScenarioDataForLive2D( Snippets.unshift(bgmSnippet); SoundData.push(soundData); } + if (FirstLayout) { + FirstLayout.forEach((l) => { + const layoutSnippet: Snippet = { + Action: SnippetAction.CharacterLayout, + ProgressBehavior: SnippetProgressBehavior.Now, + ReferenceIndex: LayoutData.length, + Delay: 0, + }; + const layoutData: LayoutData = { + Type: CharacterLayoutType.Appear, + SideFrom: l.PositionSide, + SideFromOffsetX: l.OffsetX, + SideTo: l.PositionSide, + SideToOffsetX: l.OffsetX, + DepthType: CharacterLayoutDepthType.Top, + Character2dId: l.Character2dId, + CostumeType: l.CostumeType, + MotionName: l.MotionName, + FacialName: l.FacialName, + MoveSpeedType: CharacterLayoutMoveSpeedType.Normal, + }; + Snippets.unshift(layoutSnippet); + LayoutData.push(layoutData); + }); + } return data; } From ef3ff9ebe6c03c8191240bb79b6956242d6b500d Mon Sep 17 00:00:00 2001 From: K-bai Date: Thu, 23 Jan 2025 23:02:03 +0800 Subject: [PATCH 08/10] feat(storyreader): add character filter for areatalk --- src/components/story-selector/AreaTalk.tsx | 340 +++++++++++++------ src/pages/storyreader/StoryReaderContent.tsx | 1 - src/types.d.ts | 1 + src/utils/Live2DPlayer/Live2DController.ts | 8 +- src/utils/Live2DPlayer/README.md | 5 +- src/utils/Live2DPlayer/load.ts | 29 +- src/utils/Live2DPlayer/types.d.ts | 2 - 7 files changed, 255 insertions(+), 131 deletions(-) diff --git a/src/components/story-selector/AreaTalk.tsx b/src/components/story-selector/AreaTalk.tsx index 201b3235..ee93036b 100644 --- a/src/components/story-selector/AreaTalk.tsx +++ b/src/components/story-selector/AreaTalk.tsx @@ -1,16 +1,37 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useCallback, useState } from "react"; import { Route, Switch, useRouteMatch } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; import { realityAreaWorldmap, useCachedData } from "../../utils"; +import { useCharaName } from "../../utils/i18n"; import { useRootStore } from "../../stores/root"; -import { IArea, IActionSet, ICharacter2D, ServerRegion } from "../../types.d"; - +import { + IArea, + IActionSet, + ICharacter2D, + IGameChara, + ServerRegion, +} from "../../types.d"; import { charaIcons } from "../../utils/resources"; -import { Grid, CardContent, Card, Avatar, styled } from "@mui/material"; +import { + Grid, + CardContent, + Card, + Avatar, + styled, + Stack, + Tooltip, + IconButton, + Typography, + Button, +} from "@mui/material"; +import PaperContainer from "../../components/styled/PaperContainer"; import LinkNoDecorationAlsoNoHover from "../styled/LinkNoDecorationAlsoHover"; const CardSelect = styled(Card)` + height: 100%; &:hover { cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.12); @@ -20,6 +41,117 @@ const CardSelect = styled(Card)` import { ContentTrans } from "../helpers/ContentTrans"; import ImageWrapper from "../helpers/ImageWrapper"; +const AreaCard: React.FC<{ + img: string; + to: string; + contentKey: string; + original: string; +}> = ({ img, to, contentKey, original }) => { + return ( + + + + + + + + + + + + + + + ); +}; + +const CharacterFilter: React.FC<{ + onFilter: (not_select: number[]) => void; +}> = ({ onFilter }) => { + const { t } = useTranslation(); + const [charas] = useCachedData("gameCharacters"); + const getCharaName = useCharaName(); + + const [characterNotSelected, setCharacterNotSelected] = useState( + [] + ); + + const handleCharaIconClick = useCallback( + (chara: IGameChara) => { + if (characterNotSelected.includes(chara.id)) { + setCharacterNotSelected((prevList) => + prevList.filter((id) => id !== chara.id) + ); + } else { + setCharacterNotSelected((prevList) => [...prevList, chara.id]); + } + }, + [characterNotSelected] + ); + + const handleSelectAll = () => { + setCharacterNotSelected([]); + }; + const handleSelectClear = useCallback(() => { + if (charas) setCharacterNotSelected(charas?.map((c) => c.id)); + }, [charas]); + + useEffect(() => { + onFilter(characterNotSelected); + }, [characterNotSelected]); + + return ( + + + + {t("filter:character.caption")} + + + {(charas || []).map((chara) => ( + + + handleCharaIconClick(chara)} + className={clsx({ + "icon-not-selected": characterNotSelected.includes( + chara.id + ), + "icon-selected": !characterNotSelected.includes(chara.id), + })} + > + + + + + ))} + + + + + + + + ); +}; + const AreaTalk: React.FC<{ onSetStory: (data?: { storyType: string; @@ -32,6 +164,9 @@ const AreaTalk: React.FC<{ const [actionSets] = useCachedData("actionSets"); const [chara2Ds] = useCachedData("character2ds"); const { region } = useRootStore(); + const [characterNotSelected, setCharacterNotSelected] = useState( + [] + ); const leafMatch = useRouteMatch({ path: `${path}/:areaId/:actionSetId`, @@ -52,93 +187,57 @@ const AreaTalk: React.FC<{ return ( - + {!!areas && areas .filter((area) => area.label) .map((area) => ( - - - - - - - - - - - - + ))} {!!areas && areas .filter((area) => area.areaType === "spirit_world" && !area.label) .map((area) => ( - - - - - - - - - - - - + ))} {!!areas && areas .filter((area) => area.areaType === "reality_world") .map((area, idx) => ( - - - - - - - - - - - - + ))} @@ -149,40 +248,61 @@ const AreaTalk: React.FC<{ const area = areas.find((area) => area.id === Number(areaId)); if (area && actionSets && chara2Ds) { return ( - - {actionSets - .filter((as) => as.areaId === Number(areaId)) - .map((actionSet) => ( - - + + + {actionSets + .filter( + (as) => + as.areaId === Number(areaId) && + !as.characterIds.reduce((prev, cid) => { + const characterId = chara2Ds.find( + (c2d) => c2d.id === cid + )!.characterId; + return ( + prev && characterNotSelected.includes(characterId) + ); + }, true) + ) + .map((actionSet) => ( + - - - - {actionSet.characterIds.map((charaId) => { - const characterId = chara2Ds.find( - (c2d) => c2d.id === charaId - )!.characterId; - return ( - - - - ); - })} - - - - - - ))} - + + + + + {actionSet.characterIds.map((charaId) => { + const characterId = chara2Ds.find( + (c2d) => c2d.id === charaId + )!.characterId; + return ( + + + + ); + })} + + + + + + ))} + +
); } } diff --git a/src/pages/storyreader/StoryReaderContent.tsx b/src/pages/storyreader/StoryReaderContent.tsx index bcc3e05f..5b4d2ab7 100644 --- a/src/pages/storyreader/StoryReaderContent.tsx +++ b/src/pages/storyreader/StoryReaderContent.tsx @@ -44,7 +44,6 @@ const StoryReaderContent: React.FC<{ actions: [], characters: [], }); - console.log("load"); getScenarioInfo(storyType, storyId, region) .then((info) => { if (info) { diff --git a/src/types.d.ts b/src/types.d.ts index 848c0ac1..85b882d1 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1286,6 +1286,7 @@ export interface IArea { areaType: string; viewType: string; name: string; + subName?: string; label?: string; startAt?: number; endAt?: number; diff --git a/src/utils/Live2DPlayer/Live2DController.ts b/src/utils/Live2DPlayer/Live2DController.ts index 8dcee2c3..8a40b56a 100644 --- a/src/utils/Live2DPlayer/Live2DController.ts +++ b/src/utils/Live2DPlayer/Live2DController.ts @@ -264,7 +264,9 @@ export class Live2DController extends Live2DPlayer { const wait_list = []; if (model) { if (expression !== "") { - const index = model.expressions.indexOf(expression); + const index = model.data.FileReferences.Motions.Expression.map( + (m) => m.Name + ).indexOf(expression); if (index === -1) log.error("Live2DController", `${expression} not found.`); wait_list.push( @@ -272,7 +274,9 @@ export class Live2DController extends Live2DPlayer { ); } if (motion !== "") { - const index = model.motions.indexOf(motion); + const index = model.data.FileReferences.Motions.Motion.map( + (m) => m.Name + ).indexOf(motion); if (index === -1) log.error("Live2DController", `${motion} not found.`); wait_list.push( this.layers.live2d.update_motion("Motion", costume, index) diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index ef432652..328fcf5b 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -87,6 +87,9 @@ - refactor(live2d): split live2d player action into multiple files - commit 6da200623f1aa45b47536f3bf560c604710f8aa5 - feat(live2d): add volume and animation controller -- commit this +- commit 6e7dbf7e4232e0000b1c636f1c16e2c69162aefb - fix(live2d): areatalk not load properly - fix(live2d): throw error when player finished +- commit this + - feat(storyreader): add character filter for areatalk + - fix(live2d): motions not found in model definition will block loading process \ No newline at end of file diff --git a/src/utils/Live2DPlayer/load.ts b/src/utils/Live2DPlayer/load.ts index b7f3f0f7..79f2a726 100644 --- a/src/utils/Live2DPlayer/load.ts +++ b/src/utils/Live2DPlayer/load.ts @@ -40,8 +40,6 @@ export async function getLive2DControllerData( costume: c.CostumeType, cid: c.Character2dId, data: md, - motions: md.FileReferences.Motions.Motion.map((m) => m.Name), - expressions: md.FileReferences.Motions.Expression.map((e) => e.Name), }); } return { @@ -333,27 +331,28 @@ function discardMotion( unique_motion.push(m); } }); + console.log(unique_motion); // prune modelData.forEach((md) => { const motion_for_this_model = unique_motion.filter( (m) => m.costume === md.costume ); - md.motions = motion_for_this_model + md.data.FileReferences.Motions.Motion = motion_for_this_model .filter((m) => m.type === "motion") - .map((m) => m.motion); - md.data.FileReferences.Motions.Motion = md.motions.map( - (m) => - md.data.FileReferences.Motions.Motion.find((old_m) => old_m.Name === m)! - ); - md.expressions = motion_for_this_model + .map((m) => + md.data.FileReferences.Motions.Motion.find( + (all_m) => all_m.Name === m.motion + ) + ) + .filter((m) => !!m); // skip motions that not in model defination + md.data.FileReferences.Motions.Expression = motion_for_this_model .filter((m) => m.type === "expression") - .map((m) => m.motion); - md.data.FileReferences.Motions.Expression = md.expressions.map( - (m) => + .map((m) => md.data.FileReferences.Motions.Expression.find( - (old_m) => old_m.Name === m - )! - ); + (all_m) => all_m.Name === m.motion + ) + ) + .filter((m) => !!m); // skip motions that not in model defination }); return modelData; } diff --git a/src/utils/Live2DPlayer/types.d.ts b/src/utils/Live2DPlayer/types.d.ts index 35d4580e..e213dbe0 100644 --- a/src/utils/Live2DPlayer/types.d.ts +++ b/src/utils/Live2DPlayer/types.d.ts @@ -101,8 +101,6 @@ export interface ILive2DModelDataCollection { cid: number; costume: string; data: ILive2DModelData; - motions: string[]; - expressions: string[]; } export interface ILive2DControllerData { From 886dbe8a279c9ed3948dcae7867b3115bb3e5fd8 Mon Sep 17 00:00:00 2001 From: K-bai Date: Thu, 23 Jan 2025 23:38:59 +0800 Subject: [PATCH 09/10] fix(storyreader): area sub name not display and translate --- src/components/helpers/ContentTrans.tsx | 48 +++++++++++++++++ src/components/story-selector/AreaTalk.tsx | 60 ++++++++++++++-------- src/components/story-selector/Path.tsx | 23 ++++++++- src/utils/i18n.ts | 25 ++++++++- 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/src/components/helpers/ContentTrans.tsx b/src/components/helpers/ContentTrans.tsx index 62c91e88..ec546dec 100644 --- a/src/components/helpers/ContentTrans.tsx +++ b/src/components/helpers/ContentTrans.tsx @@ -47,6 +47,54 @@ export const ContentTrans: React.FC<{ } }; +export const ContentListTrans: React.FC<{ + content: { + contentKey: string; + original: string; + }[]; + format: (content: string[]) => string; + originalProps?: TypographyProps; + translatedProps?: TypographyProps; + assetTOptions?: string | TOptions; +}> = ({ content, format, originalProps, translatedProps, assetTOptions }) => { + const { + settings: { contentTransMode }, + } = useRootStore(); + const { assetT } = useAssetI18n(); + + switch (contentTransMode) { + case "original": + return ( + + {format(content.map((c) => c.original))} + + ); + case "translated": + return ( + + {format( + content.map((c) => assetT(c.contentKey, c.original, assetTOptions)) + )} + + ); + case "both": + return ( + + + {format(content.map((c) => c.original))} + + + {format( + content.map((c) => + assetT(c.contentKey, c.original, assetTOptions) + ) + )} + + + ); + } +}; + export const CharaNameTrans: React.FC<{ characterId: number; originalProps?: TypographyProps; diff --git a/src/components/story-selector/AreaTalk.tsx b/src/components/story-selector/AreaTalk.tsx index ee93036b..8ba62b37 100644 --- a/src/components/story-selector/AreaTalk.tsx +++ b/src/components/story-selector/AreaTalk.tsx @@ -38,15 +38,16 @@ const CardSelect = styled(Card)` } `; -import { ContentTrans } from "../helpers/ContentTrans"; +import { ContentTrans, ContentListTrans } from "../helpers/ContentTrans"; import ImageWrapper from "../helpers/ImageWrapper"; const AreaCard: React.FC<{ img: string; to: string; - contentKey: string; - original: string; -}> = ({ img, to, contentKey, original }) => { + areaId: number; + areaName: string; + areaSubName?: string; +}> = ({ img, to, areaId, areaName, areaSubName }) => { return ( @@ -60,12 +61,30 @@ const AreaCard: React.FC<{ - + {areaSubName ? ( + `${content[0]}-${content[1]}`} + originalProps={{ style: { overflow: "hidden" } }} + translatedProps={{ style: { overflow: "hidden" } }} + /> + ) : ( + + )} @@ -201,10 +220,9 @@ const AreaTalk: React.FC<{ "0" )}.webp`} to={`${path}/${area.id}`} - contentKey={`area_name:${area.id}`} - original={ - area.subName ? `${area.name}/${area.subName}` : area.name - } + areaId={area.id} + areaName={area.name} + areaSubName={area.subName} /> ))} {!!areas && @@ -217,10 +235,9 @@ const AreaTalk: React.FC<{ area.id ).padStart(2, "0")}.webp`} to={`${path}/${area.id}`} - contentKey={`area_name:${area.id}`} - original={ - area.subName ? `${area.name}/${area.subName}` : area.name - } + areaId={area.id} + areaName={area.name} + areaSubName={area.subName} /> ))} {!!areas && @@ -233,10 +250,9 @@ const AreaTalk: React.FC<{ realityAreaWorldmap[String(idx + 1)] ).padStart(2, "0")}.webp`} to={`${path}/${area.id}`} - contentKey={`area_name:${area.id}`} - original={ - area.subName ? `${area.name}/${area.subName}` : area.name - } + areaId={area.id} + areaName={area.name} + areaSubName={area.subName} /> ))} diff --git a/src/components/story-selector/Path.tsx b/src/components/story-selector/Path.tsx index f16c2437..ef5043be 100644 --- a/src/components/story-selector/Path.tsx +++ b/src/components/story-selector/Path.tsx @@ -30,7 +30,7 @@ const Path: React.FC<{ }; }> = ({ catagory }) => { const { t } = useTranslation(); - const { getTranslated } = useAssetI18n(); + const { getTranslated, getListTranslated } = useAssetI18n(); const getCharaName = useCharaName(); const [unitProfiles] = useCachedData("unitProfiles"); const [unitStories] = useCachedData("unitStories"); @@ -184,7 +184,26 @@ const Path: React.FC<{ (area) => area.id === Number(pathname) ); if (area) { - name = getTranslated(`area_name:${area.id}`, area.name); + if (area.subName) { + name = getListTranslated( + [ + { + key: `area_name:${area.id}`, + original: area.name, + }, + { + key: `area_subname:${area.id}`, + original: area.subName, + }, + ], + (content) => `${content[0]}-${content[1]}` + ); + } else { + name = getTranslated( + `area_name:${area.id}`, + area.name + ); + } } } if (idx === 3) { diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index c86fc691..abeb8cdb 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -101,6 +101,7 @@ export async function initGlobalI18n() { "cheerful_carnival_teams", "cheerful_carnival_themes", "area_name", + "area_sub_name", ], returnEmptyString: false, }); @@ -147,7 +148,29 @@ export function useAssetI18n() { }, [assetT, contentTransMode] ); - return { assetI18n, assetT, getTranslated }; + const getListTranslated = useCallback( + ( + content: { + key: string; + original: string; + }[], + format: (content: string[]) => string, + options?: string | TOptions + ) => { + switch (contentTransMode) { + case "original": + return format(content.map((c) => c.original)); + case "translated": + return format(content.map((c) => assetT(c.key, c.original, options))); + case "both": + return `${format(content.map((c) => c.original))} | ${format( + content.map((c) => assetT(c.key, c.original, options)) + )}`; + } + }, + [assetT, contentTransMode] + ); + return { assetI18n, assetT, getTranslated, getListTranslated }; } export function useCharaName(forceTransMode?: ContentTransModeType) { From 5b1be5f8ba6fb12e98cb007781ccbdc4ebf79cb1 Mon Sep 17 00:00:00 2001 From: K-bai Date: Fri, 24 Jan 2025 16:14:46 +0800 Subject: [PATCH 10/10] refactor(live2d): create live2d loading module for live2d showcase and live2d reader --- .../story-selector/StorySelector.tsx | 13 +- src/pages/live2d/Live2D.tsx | 148 +++++------------- src/utils/Live2DPlayer/README.md | 8 +- src/utils/Live2DPlayer/load.ts | 94 +---------- src/utils/Live2DPlayer/types.d.ts | 32 +--- src/utils/live2dLoader.ts | 118 ++++++++++++++ 6 files changed, 177 insertions(+), 236 deletions(-) create mode 100644 src/utils/live2dLoader.ts diff --git a/src/components/story-selector/StorySelector.tsx b/src/components/story-selector/StorySelector.tsx index b8061670..9ebe40f5 100644 --- a/src/components/story-selector/StorySelector.tsx +++ b/src/components/story-selector/StorySelector.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Route, Switch, useRouteMatch } from "react-router-dom"; @@ -33,6 +33,17 @@ const StorySelector: React.FC<{ const handleSetStory = onSetStory; + const rootMatch = useRouteMatch({ + path: `${path}`, + strict: true, + exact: true, + }); + useEffect(() => { + if (rootMatch) { + onSetStory(); + } + }, [rootMatch, onSetStory]); + const catagory: { [key: string]: { breadcrumbName: string; diff --git a/src/pages/live2d/Live2D.tsx b/src/pages/live2d/Live2D.tsx index 3562938f..00e70f78 100644 --- a/src/pages/live2d/Live2D.tsx +++ b/src/pages/live2d/Live2D.tsx @@ -36,14 +36,13 @@ import JSZip from "jszip"; import { saveAs } from "file-saver"; import fscreen from "fscreen"; import { useLive2dModelList } from "../../utils/apiClient"; -import { assetUrl } from "../../utils/urls"; import TypographyHeader from "../../components/styled/TypographyHeader"; import ContainerContent from "../../components/styled/ContainerContent"; import { Stage } from "@pixi/react"; // import { settings } from "pixi.js"; import Live2dModel from "../../components/pixi/Live2dModel"; import { InternalModel, Live2DModel } from "pixi-live2d-display-mulmotion"; -import { getMotionBaseName } from "../../utils/Live2DPlayer/load"; +import { getModelData } from "../../utils/live2dLoader"; // settings.RESOLUTION = window.devicePixelRatio * 2; @@ -55,7 +54,6 @@ const Live2DView: React.FC = () => { null ); const [modelName, setModelName] = useState(""); - const [motionName, setMotionName] = useState(""); const [modelData, setModelData] = useState>(); const [motions, setMotions] = useState([]); const [selectedMotion, setSelectedMotion] = useState(null); @@ -145,83 +143,28 @@ const Live2DView: React.FC = () => { setProgress(0); setProgressWords(t("live2d:load_progress.model_metadata")); - const { data: modelData } = await Axios.get<{ - Moc3FileName: string; - TextureNames: string[]; - PhysicsFileName: string; - UserDataFileName: string; - AdditionalMotionData: unknown[]; - CategoryRules: unknown[]; - }>( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/buildmodeldata.asset`, - { responseType: "json" } - ); + const modelData = await getModelData(modelName); setProgress(20); setProgressWords(t("live2d:load_progress.model_texture")); - await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${modelData.TextureNames[0]}` - ); + await Axios.get(modelData.FileReferences.Textures[0]); setProgress(40); setProgressWords(t("live2d:load_progress.model_moc3")); - await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${modelData.Moc3FileName}` - ); + await Axios.get(modelData.FileReferences.Moc); setProgress(60); setProgressWords(t("live2d:load_progress.model_physics")); - await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${modelData.PhysicsFileName}` - ); - - let motionData; - if (!modelName.startsWith("normal")) { - setProgress(80); - setProgressWords(t("live2d:load_progress.motion_metadata")); - const motionRes = await Axios.get<{ - motions: string[]; - expressions: string[]; - }>( - `${assetUrl.minio.jp}/live2d/motion/${motionName}_rip/BuildMotionData.json`, - { responseType: "json" } - ); - motionData = motionRes.data; - } else { - motionData = { - expressions: [], - motions: [], - }; - } + await Axios.get(modelData.FileReferences.Physics); setProgress(90); setProgressWords(t("live2d:load_progress.display_model")); - const filename = modelData.Moc3FileName.replace( - ".moc3.bytes", - ".model3.json" + setModelData(modelData); + + setMotions(modelData.FileReferences.Motions.Motion.map((m) => m.Name)); + setExpressions( + modelData.FileReferences.Motions.Expression.map((m) => m.Name) ); - const model3Json = ( - await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${filename}` - ) - ).data; - model3Json.url = `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/`; - model3Json.FileReferences.Moc = `${model3Json.FileReferences.Moc}.bytes`; - model3Json.FileReferences.Motions = { - Motion: motionData.motions.map((elem) => ({ - File: `../../motion/${motionName}_rip/${elem}.motion3.json`, - FadeInTime: 1, - FadeOutTime: 1, - })), - Expression: motionData.expressions.map((elem) => ({ - Name: elem, - File: `../../motion/${motionName}_rip/${elem}.motion3.json`, - })), - }; - setModelData(model3Json); - - setMotions(motionData.motions); - setExpressions(motionData.expressions); setShowProgress(false); setProgress(0); @@ -230,7 +173,7 @@ const Live2DView: React.FC = () => { }; func(); - }, [modelName, motionName, t]); + }, [modelName, t]); const handleDownload = useCallback(async () => { setShowProgress(true); @@ -238,22 +181,14 @@ const Live2DView: React.FC = () => { setProgressWords(t("live2d:pack_progress.generate_metadata")); const zip = new JSZip(); - const { data: modelData } = await Axios.get<{ - Moc3FileName: string; - TextureNames: string[]; - PhysicsFileName: string; - UserDataFileName: string; - AdditionalMotionData: unknown[]; - CategoryRules: unknown[]; - }>( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/buildmodeldata.asset`, - { responseType: "json" } - ); - + const modelData = await getModelData(modelName!); const model3 = { FileReferences: { Moc: `${modelName}.moc3`, - Motions: [...motions, ...expressions].reduce<{ + Motions: [ + ...modelData.FileReferences.Motions.Motion, + ...modelData.FileReferences.Motions.Expression, + ].reduce<{ [key: string]: [ { File: string; @@ -261,19 +196,16 @@ const Live2DView: React.FC = () => { FadeOutTime: number; }, ]; - }>( - (sum, elem) => - Object.assign({}, sum, { - [elem]: [ - { - FadeInTime: 0.5, - FadeOutTime: 0.5, - File: `motions/${elem}.motion3.json`, - }, - ], - }), - {} - ), + }>((prev, m) => { + prev[m.Name] = [ + { + FadeInTime: 0.5, + FadeOutTime: 0.5, + File: `motions/${m.Name}.motion3.json`, + }, + ]; + return prev; + }, {}), Physics: `${modelName}.physics3.json`, Textures: [`${modelName}.2048/texture_00.png`], }, @@ -297,7 +229,7 @@ const Live2DView: React.FC = () => { setProgress(10); setProgressWords(t("live2d:pack_progress.download_texture")); const { data: texture } = await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${modelData.TextureNames[0]}`, + modelData.url + modelData.FileReferences.Textures[0], { responseType: "blob" } ); @@ -306,7 +238,7 @@ const Live2DView: React.FC = () => { setProgress(20); setProgressWords(t("live2d:pack_progress.download_moc3")); const { data: moc3 } = await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${modelData.Moc3FileName}`, + modelData.url + modelData.FileReferences.Moc, { responseType: "blob" } ); @@ -315,7 +247,7 @@ const Live2DView: React.FC = () => { setProgress(30); setProgressWords(t("live2d:pack_progress.download_physics")); const { data: physics } = await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${modelData.PhysicsFileName}`, + modelData.url + modelData.FileReferences.Physics, { responseType: "blob" } ); @@ -337,17 +269,17 @@ const Live2DView: React.FC = () => { ); const tasks = []; - for (const [name, motion] of Object.entries( - model3.FileReferences.Motions - )) { + for (const motion of [ + ...modelData.FileReferences.Motions.Motion, + ...modelData.FileReferences.Motions.Expression, + ]) { tasks.push( - Axios.get( - `${assetUrl.minio.jp}/live2d/motion/${motionName}_rip/${name}.motion3.json`, - { responseType: "blob" } - ).then(({ data }) => { + Axios.get(modelData.url + motion.File, { + responseType: "blob", + }).then(({ data }) => { updateCount(); - zip.file(motion[0].File, data); + zip.file(model3.FileReferences.Motions[motion.Name][0].File, data); }) ); } @@ -362,7 +294,7 @@ const Live2DView: React.FC = () => { setShowProgress(false); setProgress(0); setProgressWords(""); - }, [expressions, modelName, motionName, motions, t]); + }, [expressions, modelName, motions, t]); const handleScreenshot = useCallback(() => { if (stage.current && live2dModel.current) { @@ -389,10 +321,6 @@ const Live2DView: React.FC = () => { const handleShow = useCallback(() => { setModelName(selectedModelName); - let motionName = selectedModelName; - if (!motionName) return; - motionName = getMotionBaseName(motionName); - setMotionName(motionName); }, [selectedModelName]); const onLive2dModelReady = useCallback(() => { diff --git a/src/utils/Live2DPlayer/README.md b/src/utils/Live2DPlayer/README.md index 328fcf5b..cdc5faf8 100644 --- a/src/utils/Live2DPlayer/README.md +++ b/src/utils/Live2DPlayer/README.md @@ -90,6 +90,10 @@ - commit 6e7dbf7e4232e0000b1c636f1c16e2c69162aefb - fix(live2d): areatalk not load properly - fix(live2d): throw error when player finished -- commit this +- commit 6e7dbf7e4232e0000b1c636f1c16e2c69162aefb - feat(storyreader): add character filter for areatalk - - fix(live2d): motions not found in model definition will block loading process \ No newline at end of file + - fix(live2d): motions not found in model definition will block loading process +- commit eb302607fa268c9fa32f799fef080f33c9c73c4a + - fix(storyreader): area sub name not display and translate +- commit this + - refactor(live2d): create live2d loading module for live2d showcase and live2d reader \ No newline at end of file diff --git a/src/utils/Live2DPlayer/load.ts b/src/utils/Live2DPlayer/load.ts index 79f2a726..11cf6cd9 100644 --- a/src/utils/Live2DPlayer/load.ts +++ b/src/utils/Live2DPlayer/load.ts @@ -1,6 +1,5 @@ import Axios from "axios"; import { Howl } from "howler"; -import { assetUrl } from "../urls"; import { SnippetAction } from "../../types.d"; import type { IScenarioData } from "../../types.d"; @@ -8,15 +7,14 @@ import { Live2DAssetTypeImage, Live2DAssetTypeSound } from "./types.d"; import type { ILive2DCachedAsset, ILive2DAssetUrl, - ILive2DModelData, ILive2DControllerData, ILive2DModelDataCollection, IProgressEvent, } from "./types.d"; import { getUIMediaUrls } from "./ui_assets"; - import { PreloadQueue } from "./PreloadQueue"; +import { getModelData } from "../live2dLoader"; // step 3 - get controller data (preload media) export async function getLive2DControllerData( @@ -35,7 +33,7 @@ export async function getLive2DControllerData( for (const c of snData.AppearCharacters) { count++; progress("model_data", count, total, c.CostumeType); - const md = await getModelData(c.CostumeType); + const md = await getModelData(c.CostumeType, [0.5, 0.1], [0.1, 0.1]); modelData.push({ costume: c.CostumeType, cid: c.Character2dId, @@ -148,94 +146,6 @@ function preloadSound(url: string): Promise { }); }); } -// step 3.3 - get model data -export async function getModelData( - modelName: string -): Promise { - // step 3.3.1 - get model build data - const { data: modelData } = await Axios.get<{ - Moc3FileName: string; - TextureNames: string[]; - PhysicsFileName: string; - UserDataFileName: string; - AdditionalMotionData: unknown[]; - CategoryRules: unknown[]; - }>( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/buildmodeldata.asset`, - { responseType: "json" } - ); - // step 3.3.2 - get motion data - const motionName = getMotionBaseName(modelName); - let motionData; - if (!modelName.startsWith("normal")) { - const motionRes = await Axios.get<{ - motions: string[]; - expressions: string[]; - }>( - `${assetUrl.minio.jp}/live2d/motion/${motionName}_rip/BuildMotionData.json`, - { responseType: "json" } - ); - motionData = motionRes.data; - } else { - motionData = { - expressions: [], - motions: [], - }; - } - // step 3.3.3 - construct model - const filename = modelData.Moc3FileName.replace( - ".moc3.bytes", - ".model3.json" - ); - const model3Json = ( - await Axios.get( - `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/${filename}` - ) - ).data; - model3Json.url = `${assetUrl.minio.jp}/live2d/model/${modelName}_rip/`; - model3Json.FileReferences.Moc = `${model3Json.FileReferences.Moc}.bytes`; - model3Json.FileReferences.Motions = { - Motion: motionData.motions.map((elem) => ({ - Name: elem, - File: `../../motion/${motionName}_rip/${elem}.motion3.json`, - FadeInTime: 0.5, - FadeOutTime: 0.1, - })), - Expression: motionData.expressions.map((elem) => ({ - Name: elem, - File: `../../motion/${motionName}_rip/${elem}.motion3.json`, - FadeInTime: 0.1, - FadeOutTime: 0.1, - })), - }; - model3Json.FileReferences.Expressions = {}; - return model3Json; -} -// step 3.3.2 - get motion data -export function getMotionBaseName(modelName: string): string { - let motionName = modelName; - if (!motionName.startsWith("v2_sub") && !motionName.startsWith("sub_rival")) { - if (motionName.endsWith("_black")) { - motionName = motionName.slice(0, -6); - } else if (motionName.endsWith("black")) { - motionName = motionName.slice(0, -5); - } - if ( - motionName?.startsWith("sub") || - motionName?.startsWith("clb") || - motionName.match(/^v2_\d{2}.*/) - ) { - motionName = motionName.split("_").slice(0, 2).join("_"); - } else { - motionName = motionName.split("_")[0]!; - } - } else if (motionName?.startsWith("sub_rival")) { - motionName = motionName.split("_").slice(0, 3).join("_"); - } else if (motionName?.startsWith("v2_sub_rival")) { - motionName = motionName.split("_").slice(0, 4).join("_"); - } - return motionName + "_motion_base"; -} // step 4.2 - discard useless motions in all model function discardMotion( diff --git a/src/utils/Live2DPlayer/types.d.ts b/src/utils/Live2DPlayer/types.d.ts index e213dbe0..21127a1e 100644 --- a/src/utils/Live2DPlayer/types.d.ts +++ b/src/utils/Live2DPlayer/types.d.ts @@ -1,5 +1,5 @@ import type { Howl } from "howler"; -import type { IScenarioData } from "../../types"; +import type { IScenarioData, ILive2DModelData } from "../../types"; import type { Animation } from "./animation/BaseAnimation"; import type { Curve, CurveFunction } from "./animation/Curve"; import { Texture, DisplayObject } from "pixi.js"; @@ -46,36 +46,6 @@ export interface ILive2DTexture { identifer: string; texture: Texture; } - -export interface ILive2DModelData { - Version: number; - FileReferences: { - Moc: string; - Textures: string[]; - Physics: string; - Motions: { - Motion: { - Name: string; - File: string; - FadeInTime: number; - FadeOutTime: number; - }[]; - Expression: { - Name: string; - File: string; - FadeInTime: number; - FadeOutTime: number; - }[]; - }; - Groups: { - Target: string; - Name: string; - Ids: number[]; - }[]; - }; - url: string; -} - export interface Ilive2DModelInfo { cid: number; costume: string; diff --git a/src/utils/live2dLoader.ts b/src/utils/live2dLoader.ts new file mode 100644 index 00000000..bfec7bcd --- /dev/null +++ b/src/utils/live2dLoader.ts @@ -0,0 +1,118 @@ +import Axios from "axios"; +import { getRemoteAssetURL } from "."; +import type { ILive2DModelData } from "../types.d"; + +export async function getModelData( + modelName: string, + motionFade: [number, number] = [1, 1], + expressionFade: [number, number] = [1, 1] +): Promise { + // step 1 - get model build data + const { data: modelData } = await Axios.get<{ + Moc3FileName: string; + }>(await getBuildModelDataUrl(modelName), { responseType: "json" }); + // step 2 - get motion data + const motionBaseName = getMotionBaseName(modelName); + const motionData = await getMotionData(modelName, motionBaseName); + // step 3 - construct model + const model3Json = ( + await Axios.get(await getModel3JsonUrl(modelName, modelData.Moc3FileName)) + ).data; + model3Json.url = await getModelBaseUrl(modelName); + model3Json.FileReferences.Moc = `${model3Json.FileReferences.Moc}.bytes`; + model3Json.FileReferences.Motions = { + Motion: motionData.motions.map((elem) => ({ + Name: elem, + File: getRelativeMotionUrl(motionBaseName, elem), + FadeInTime: motionFade[0], + FadeOutTime: motionFade[1], + })), + Expression: motionData.expressions.map((elem) => ({ + Name: elem, + File: getRelativeMotionUrl(motionBaseName, elem), + FadeInTime: expressionFade[0], + FadeOutTime: expressionFade[1], + })), + }; + model3Json.FileReferences.Expressions = {}; + return model3Json; +} + +function getMotionBaseName(modelName: string): string { + let motionName = modelName; + if (!motionName.startsWith("v2_sub") && !motionName.startsWith("sub_rival")) { + if (motionName.endsWith("_black")) { + motionName = motionName.slice(0, -6); + } else if (motionName.endsWith("black")) { + motionName = motionName.slice(0, -5); + } + if ( + motionName?.startsWith("sub") || + motionName?.startsWith("clb") || + motionName.match(/^v2_\d{2}.*/) + ) { + motionName = motionName.split("_").slice(0, 2).join("_"); + } else { + motionName = motionName.split("_")[0]!; + } + } else if (motionName?.startsWith("sub_rival")) { + motionName = motionName.split("_").slice(0, 3).join("_"); + } else if (motionName?.startsWith("v2_sub_rival")) { + motionName = motionName.split("_").slice(0, 4).join("_"); + } + return motionName + "_motion_base"; +} + +async function getMotionData(modelName: string, motionBaseName: string) { + let motionData; + if (!modelName.startsWith("normal")) { + const motionRes = await Axios.get<{ + motions: string[]; + expressions: string[]; + }>(await getBuildMotionDataUrl(motionBaseName), { responseType: "json" }); + motionData = motionRes.data; + } else { + motionData = { + expressions: [], + motions: [], + }; + } + return motionData; +} + +async function getBuildModelDataUrl(modelName: string) { + return await getRemoteAssetURL( + `live2d/model/${modelName}_rip/buildmodeldata.asset`, + undefined, + "minio" + ); +} + +async function getBuildMotionDataUrl(motionBaseName: string) { + return await getRemoteAssetURL( + `live2d/motion/${motionBaseName}_rip/BuildMotionData.json`, + undefined, + "minio" + ); +} + +async function getModelBaseUrl(modelName: string) { + return await getRemoteAssetURL( + `live2d/model/${modelName}_rip/`, + undefined, + "minio" + ); +} + +async function getModel3JsonUrl(modelName: string, moc3FileName: string) { + const filename = moc3FileName.replace(".moc3.bytes", ".model3.json"); + return await getRemoteAssetURL( + `live2d/model/${modelName}_rip/${filename}`, + undefined, + "minio" + ); +} + +function getRelativeMotionUrl(motionBaseName: string, motion: string) { + return `../../motion/${motionBaseName}_rip/${motion}.motion3.json`; +}