Files
2026-09-09 14:47:29 +08:00

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,
}