428 lines
12 KiB
JavaScript
428 lines
12 KiB
JavaScript
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,
|
|
}
|