gengxin
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
const COMIC_PAGE_TYPES = Object.freeze([
|
||||
'cover',
|
||||
'ensemble',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'emotion',
|
||||
'memory',
|
||||
])
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicPages(model = {}) {
|
||||
return Array.isArray(model.pageSequence) ? model.pageSequence : []
|
||||
}
|
||||
|
||||
function pageIndex(model, pageId) {
|
||||
const requested = cleanString(pageId)
|
||||
if (!requested) return -1
|
||||
return comicPages(model).findIndex((page) => page.pageId === requested)
|
||||
}
|
||||
|
||||
function eventPages(model) {
|
||||
return comicPages(model).filter((page) => page.type === 'event')
|
||||
}
|
||||
|
||||
function isEightPageComicModel(model = {}) {
|
||||
const pages = comicPages(model)
|
||||
if (
|
||||
model.mode !== 'comic'
|
||||
|| !cleanString(model.chapterId)
|
||||
|| pages.length !== COMIC_PAGE_TYPES.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return pages.every((page, index) => (
|
||||
page
|
||||
&& page.pageId === `${model.chapterId}-P${String(index + 1).padStart(2, '0')}`
|
||||
&& page.type === COMIC_PAGE_TYPES[index]
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a contiguous prefix of P03-P06 is trusted.
|
||||
*
|
||||
* This prevents stale, foreign, duplicated or out-of-order ids from opening a
|
||||
* later page. It also keeps the locked 60-event source order unchanged.
|
||||
*/
|
||||
function normalizeCompletedEventIds(model, completedIds = []) {
|
||||
const supplied = new Set(
|
||||
(Array.isArray(completedIds) ? completedIds : [])
|
||||
.map(cleanString)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const normalized = []
|
||||
for (const page of eventPages(model)) {
|
||||
if (!supplied.has(page.eventId)) break
|
||||
normalized.push(page.eventId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getUnlockedPageIndex(
|
||||
model,
|
||||
completedIds = [],
|
||||
chapterFinished = false,
|
||||
) {
|
||||
const pages = comicPages(model)
|
||||
if (!pages.length) return -1
|
||||
if (!isEightPageComicModel(model)) return 0
|
||||
if (chapterFinished) return pages.length - 1
|
||||
|
||||
const completed = normalizeCompletedEventIds(model, completedIds)
|
||||
const firstEventIndex = pages.findIndex((page) => page.type === 'event')
|
||||
const emotionIndex = pages.findIndex((page) => page.type === 'emotion')
|
||||
if (firstEventIndex < 0) return 0
|
||||
|
||||
return Math.min(
|
||||
emotionIndex >= 0 ? emotionIndex : pages.length - 1,
|
||||
firstEventIndex + completed.length,
|
||||
)
|
||||
}
|
||||
|
||||
function emptyReaderState() {
|
||||
return {
|
||||
valid: false,
|
||||
currentPage: null,
|
||||
currentPageId: '',
|
||||
currentPageIndex: -1,
|
||||
unlockedPageIndex: -1,
|
||||
unlockedPageIds: [],
|
||||
completedEventIds: [],
|
||||
allEventsComplete: false,
|
||||
chapterFinished: false,
|
||||
canGoPrevious: false,
|
||||
canGoNext: false,
|
||||
nextPageLocked: false,
|
||||
activeInteraction: null,
|
||||
activeHotspots: [],
|
||||
pageAccess: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildComicReaderState(model, progress = {}) {
|
||||
if (!isEightPageComicModel(model)) return emptyReaderState()
|
||||
|
||||
const pages = comicPages(model)
|
||||
const completedEventIds = normalizeCompletedEventIds(
|
||||
model,
|
||||
progress.completedEventIds || progress.completedIds,
|
||||
)
|
||||
const allEventsComplete = (
|
||||
completedEventIds.length === eventPages(model).length
|
||||
)
|
||||
const chapterFinished = Boolean(
|
||||
progress.chapterFinished && allEventsComplete,
|
||||
)
|
||||
const unlockedPageIndex = getUnlockedPageIndex(
|
||||
model,
|
||||
completedEventIds,
|
||||
chapterFinished,
|
||||
)
|
||||
const requestedPageId = (
|
||||
cleanString(progress.currentPageId)
|
||||
|| cleanString(progress.savedPageId)
|
||||
|| model.firstPageId
|
||||
|| pages[0].pageId
|
||||
)
|
||||
const requestedIndex = pageIndex(model, requestedPageId)
|
||||
const currentPageIndex = Math.min(
|
||||
unlockedPageIndex,
|
||||
Math.max(0, requestedIndex >= 0 ? requestedIndex : 0),
|
||||
)
|
||||
const currentPage = pages[currentPageIndex]
|
||||
const completed = new Set(completedEventIds)
|
||||
|
||||
let activeInteraction = null
|
||||
if (
|
||||
currentPage.type === 'event'
|
||||
&& !completed.has(currentPage.eventId)
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'event',
|
||||
id: currentPage.eventId,
|
||||
eventId: currentPage.eventId,
|
||||
pageId: currentPage.pageId,
|
||||
actorInstanceId: currentPage.actorInstanceId || '',
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
} else if (
|
||||
currentPage.type === 'emotion'
|
||||
&& allEventsComplete
|
||||
&& !chapterFinished
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'emotion',
|
||||
id: currentPage.emotionMomentId,
|
||||
emotionMomentId: currentPage.emotionMomentId,
|
||||
pageId: currentPage.pageId,
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
}
|
||||
|
||||
const activeHotspots = (
|
||||
activeInteraction
|
||||
&& activeInteraction.actorHotspot
|
||||
)
|
||||
? [{
|
||||
interactionId: activeInteraction.id,
|
||||
pageId: activeInteraction.pageId,
|
||||
actorHotspot: activeInteraction.actorHotspot,
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
currentPage,
|
||||
currentPageId: currentPage.pageId,
|
||||
currentPageIndex,
|
||||
unlockedPageIndex,
|
||||
unlockedPageIds: pages
|
||||
.slice(0, unlockedPageIndex + 1)
|
||||
.map((page) => page.pageId),
|
||||
completedEventIds,
|
||||
allEventsComplete,
|
||||
chapterFinished,
|
||||
canGoPrevious: currentPageIndex > 0,
|
||||
canGoNext: currentPageIndex < unlockedPageIndex,
|
||||
nextPageLocked: (
|
||||
currentPageIndex < pages.length - 1
|
||||
&& currentPageIndex >= unlockedPageIndex
|
||||
),
|
||||
activeInteraction,
|
||||
activeHotspots,
|
||||
pageAccess: pages.map((page, index) => ({
|
||||
pageId: page.pageId,
|
||||
pageIndex: index,
|
||||
type: page.type,
|
||||
unlocked: index <= unlockedPageIndex,
|
||||
current: index === currentPageIndex,
|
||||
interactionEnabled: Boolean(
|
||||
activeInteraction
|
||||
&& activeInteraction.pageId === page.pageId
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function progressFromState(state) {
|
||||
return {
|
||||
currentPageId: state.currentPageId,
|
||||
completedEventIds: [...state.completedEventIds],
|
||||
chapterFinished: state.chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one chapter from the shared v1 storage object.
|
||||
*
|
||||
* `comicReaderByChapter` is the canonical reader cursor. The older
|
||||
* completedHotspots/completedChapters/lastPageId fields remain mirrored for
|
||||
* catalog compatibility and safe migration from already-installed builds.
|
||||
*/
|
||||
function readComicReaderProgress(storageProgress = {}, model = {}, chapterNumber) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
const storedByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? storageProgress.comicReaderByChapter
|
||||
: {}
|
||||
const storedChapter = (
|
||||
chapterId
|
||||
&& storedByChapter[chapterId]
|
||||
&& typeof storedByChapter[chapterId] === 'object'
|
||||
)
|
||||
? storedByChapter[chapterId]
|
||||
: {}
|
||||
const legacyHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
&& Array.isArray(storageProgress.completedHotspots[chapterId])
|
||||
)
|
||||
? storageProgress.completedHotspots[chapterId]
|
||||
: []
|
||||
const legacyFinished = (
|
||||
Array.isArray(storageProgress.completedChapters)
|
||||
&& storageProgress.completedChapters.includes(chapterId)
|
||||
)
|
||||
const chapterFinished = (
|
||||
typeof storedChapter.chapterFinished === 'boolean'
|
||||
? storedChapter.chapterFinished
|
||||
: legacyFinished
|
||||
)
|
||||
const isLastChapter = (
|
||||
Number(storageProgress.lastChapter) === Number(chapterNumber)
|
||||
)
|
||||
|
||||
return {
|
||||
currentPageId: (
|
||||
cleanString(storedChapter.currentPageId)
|
||||
|| (isLastChapter ? cleanString(storageProgress.lastPageId) : '')
|
||||
|| (chapterFinished ? cleanString(model.lastPageId) : '')
|
||||
|| cleanString(model.firstPageId)
|
||||
),
|
||||
completedEventIds: Array.isArray(storedChapter.completedEventIds)
|
||||
? [...storedChapter.completedEventIds]
|
||||
: [...legacyHotspots],
|
||||
chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge only the current chapter back into shared storage.
|
||||
*
|
||||
* Other chapters and unrelated product fields are copied through untouched.
|
||||
* The supplied reader progress is normalized again here so stale `finished`
|
||||
* flags and out-of-order future event ids cannot be persisted.
|
||||
*/
|
||||
function mergeComicReaderProgress(
|
||||
storageProgress = {},
|
||||
model = {},
|
||||
chapterNumber,
|
||||
readerProgress = {},
|
||||
) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
if (!chapterId || !isEightPageComicModel(model)) {
|
||||
return { ...storageProgress }
|
||||
}
|
||||
const state = buildComicReaderState(model, readerProgress)
|
||||
const normalized = progressFromState(state)
|
||||
const completedHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
)
|
||||
? { ...storageProgress.completedHotspots }
|
||||
: {}
|
||||
const comicReaderByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? { ...storageProgress.comicReaderByChapter }
|
||||
: {}
|
||||
const completedChapters = Array.isArray(storageProgress.completedChapters)
|
||||
? storageProgress.completedChapters.filter(
|
||||
(storedChapterId) => storedChapterId !== chapterId,
|
||||
)
|
||||
: []
|
||||
|
||||
completedHotspots[chapterId] = [...normalized.completedEventIds]
|
||||
comicReaderByChapter[chapterId] = normalized
|
||||
if (normalized.chapterFinished) completedChapters.push(chapterId)
|
||||
|
||||
return {
|
||||
...storageProgress,
|
||||
completedHotspots,
|
||||
completedChapters,
|
||||
comicReaderByChapter,
|
||||
lastChapter: Number(chapterNumber) || model.chapterNumber || 1,
|
||||
lastPageId: normalized.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transition helper. Completing an interaction unlocks the following
|
||||
* page but never turns it automatically, so the reader still controls the
|
||||
* lianhuanhua rhythm.
|
||||
*/
|
||||
function applyComicReaderAction(model, progress = {}, action = {}) {
|
||||
const state = buildComicReaderState(model, progress)
|
||||
if (!state.valid) return progressFromState(state)
|
||||
const type = cleanString(action.type)
|
||||
let nextProgress = progressFromState(state)
|
||||
|
||||
if (type === 'previous-page' && state.canGoPrevious) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex - 1
|
||||
].pageId
|
||||
} else if (type === 'next-page' && state.canGoNext) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex + 1
|
||||
].pageId
|
||||
} else if (type === 'open-page') {
|
||||
const targetIndex = pageIndex(model, action.pageId)
|
||||
if (targetIndex >= 0 && targetIndex <= state.unlockedPageIndex) {
|
||||
nextProgress.currentPageId = comicPages(model)[targetIndex].pageId
|
||||
}
|
||||
} else if (
|
||||
type === 'complete-event'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'event'
|
||||
&& state.activeInteraction.eventId === cleanString(action.eventId)
|
||||
) {
|
||||
nextProgress.completedEventIds = [
|
||||
...state.completedEventIds,
|
||||
state.activeInteraction.eventId,
|
||||
]
|
||||
} else if (
|
||||
type === 'complete-emotion'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'emotion'
|
||||
&& state.activeInteraction.emotionMomentId
|
||||
=== cleanString(action.emotionMomentId)
|
||||
) {
|
||||
nextProgress.chapterFinished = true
|
||||
}
|
||||
|
||||
return progressFromState(buildComicReaderState(model, nextProgress))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared illustration path is not proof that a finished page exists.
|
||||
* Callers must explicitly confirm formalAvailable after package/manifest
|
||||
* validation. Otherwise the state is visibly a fallback, never "formal art".
|
||||
*/
|
||||
function buildComicArtAvailability(page = {}, options = {}) {
|
||||
const formalAsset = cleanString(
|
||||
options.formalAsset || page.illustrationAsset,
|
||||
)
|
||||
const fallbackAsset = cleanString(
|
||||
options.fallbackAsset || page.fallbackAsset,
|
||||
)
|
||||
if (options.formalAvailable === true && formalAsset) {
|
||||
return {
|
||||
mode: 'formal-art',
|
||||
src: formalAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: true,
|
||||
isFallback: false,
|
||||
}
|
||||
}
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
mode: 'scene-fallback',
|
||||
src: fallbackAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'text-only',
|
||||
src: '',
|
||||
formalAsset,
|
||||
fallbackAsset: '',
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_PAGE_TYPES,
|
||||
applyComicReaderAction,
|
||||
buildComicArtAvailability,
|
||||
buildComicReaderState,
|
||||
getUnlockedPageIndex,
|
||||
isEightPageComicModel,
|
||||
mergeComicReaderProgress,
|
||||
normalizeCompletedEventIds,
|
||||
pageIndex,
|
||||
readComicReaderProgress,
|
||||
}
|
||||
Reference in New Issue
Block a user