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

1250 lines
49 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const COMIC_CHAPTER_ID = 'S01-C04'
const CHAPTER_ONE_ID = 'S01-C01'
const CHAPTER_TWO_ID = 'S01-C02'
const CHAPTER_THREE_ID = 'S01-C03'
const CHAPTER_FIVE_ID = 'S01-C05'
const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({
'S01-C04-P01': Object.freeze(['S01-C04-MT000']),
'S01-C04-P02': Object.freeze([
'S01-C04-MS001',
'S01-C04-MS002-ATTR',
'S01-C04-MS002',
]),
'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']),
'S01-C04-P04': Object.freeze(['S01-C04-MS005']),
'S01-C04-P05': Object.freeze([
'S01-C04-MS006',
'S01-C04-MS007',
'S01-C04-MS008',
'S01-C04-MS009',
'S01-C04-MS010',
'S01-C04-MS011',
]),
'S01-C04-P06': Object.freeze([
'S01-C04-MS012-ATTR',
'S01-C04-MS012',
'S01-C04-MS013',
'S01-C04-MS014',
]),
'S01-C04-P07': Object.freeze(['S01-C04-TE900']),
'S01-C04-P08': Object.freeze(['S01-C04-MS015']),
})
// This generated data is copied into every chapter subpackage. Keeping the
// dependency package-local is important: a file referenced only from a
// subpackage can otherwise be omitted from the main package by WeChat's
// unused-file optimisation, leaving the chapter page blank at runtime.
const productionComicChapters = require('../../data/productionComicPages')
const memoryCards = require('../../../data/memoryCards')
const {
attachPlayableVisuals,
} = require('../../data/playableVisualPolicy')
const {
allowsFixedDetailInset,
getComicPresentationMode,
getComicPageLayoutException,
resolveComicPageLayout,
} = require('./chapterLayout')
function chapterPackageRoot(value) {
const chapter = Math.min(
15,
Math.max(1, Math.floor(Number(value) || 1)),
)
return chapter === 1
? 'package-game'
: `package-chapter-${String(chapter).padStart(2, '0')}`
}
function pageId(chapterId, pageNumber) {
return `${chapterId}-P${String(pageNumber).padStart(2, '0')}`
}
function findPerson(chapter, instanceId) {
return (chapter.people || []).find(
(person) => person.instanceId === instanceId,
) || null
}
function actorHotspot(personOrPosition) {
const position = personOrPosition && personOrPosition.position
? personOrPosition.position
: (personOrPosition || {})
const x = Math.max(0, Number(position.xPercent) || 0)
const y = Math.max(0, Number(position.yPercent) || 0)
const width = Math.max(12, Number(position.widthPercent) || 18)
const height = Math.max(22, Number(position.heightPercent) || 48)
const expandedX = Math.max(0, x - 2.5)
const expandedY = Math.max(0, y - 3)
const expandedWidth = Math.min(100 - expandedX, width + 5)
const expandedHeight = Math.min(100 - expandedY, height + 6)
return {
xPercent: expandedX,
yPercent: expandedY,
widthPercent: expandedWidth,
heightPercent: expandedHeight,
style: [
`left:${expandedX}%`,
`top:${expandedY}%`,
`width:${expandedWidth}%`,
`height:${expandedHeight}%`,
].join(';'),
}
}
function fixedHotspot(position) {
const x = Math.max(0, Number(position && position.x) || 0)
const y = Math.max(0, Number(position && position.y) || 0)
const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18))
const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48))
return {
xPercent: x,
yPercent: y,
widthPercent: width,
heightPercent: height,
style: [
`left:${x}%`,
`top:${y}%`,
`width:${width}%`,
`height:${height}%`,
].join(';'),
}
}
function fixedDetailInset(position) {
if (!position || typeof position !== 'object') return null
const x = Math.max(0, Math.min(99, Number(position.x) || 0))
const y = Math.max(0, Math.min(99, Number(position.y) || 0))
const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1))
const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1))
const requestedAnchor = String(position.anchor || '')
const anchor = requestedAnchor === 'top-right'
|| requestedAnchor === 'top-center'
? requestedAnchor
: 'top-left'
const labelAnchorClass = position.labelAnchor === 'bottom-left'
? 'label-bottom-left'
: ''
const imageWidth = 10000 / width
const imageHeight = 10000 / height
const imageLeft = -(x / width * 100)
const imageTop = -(y / height * 100)
return {
label: String(position.label || ''),
xPercent: x,
yPercent: y,
widthPercent: width,
heightPercent: height,
anchor,
anchorClass: `anchor-${anchor}`,
...(labelAnchorClass ? { labelAnchorClass } : {}),
imageMode: 'scaleToFill',
imageStyle: [
`left:${imageLeft.toFixed(3)}%`,
`top:${imageTop.toFixed(3)}%`,
`width:${imageWidth.toFixed(3)}%`,
`height:${imageHeight.toFixed(3)}%`,
].join(';'),
}
}
function productionPageFor(stablePageId) {
const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || ''))
const productionChapter = match
? productionComicChapters[match[1]]
: null
const pages = productionChapter && productionChapter.pages
return Array.isArray(pages)
? pages.find((page) => page.pageId === stablePageId) || null
: null
}
function attachFixedDetailInset(page) {
if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) {
return page
}
const productionPage = productionPageFor(page.pageId)
const detailInset = fixedDetailInset(
productionPage && productionPage.programDetailInset,
)
return detailInset ? { ...page, detailInset } : page
}
function fixedProgramEvidenceInset(source) {
if (!source || typeof source !== 'object') return null
const countValue = Number(source.countValue)
if (
source.kind !== 'order-count'
|| !Number.isInteger(countValue)
|| countValue < 0
) {
return null
}
const anchor = source.anchor === 'top-right'
? 'top-right'
: 'top-left'
return {
kind: 'order-count',
kicker: String(source.kicker || '画中近景'),
screenLabel: String(source.screenLabel || '手机点单'),
countLabel: String(source.countLabel || '已点'),
countValue,
countUnit: String(source.countUnit || '道'),
plusGlyph: String(source.plusGlyph || ''),
note: String(source.note || ''),
ariaLabel: String(source.ariaLabel || ''),
anchor,
anchorClass: `anchor-${anchor}`,
}
}
// Keep internal provenance / implementation language out of the reader-facing story.
function playerFacingCopy(value) {
return String(value || '')
.replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定')
.replace(/AI/g, '手机')
.replace(/证据链/g, '前后几样线索')
.replace(/证据来源/g, '物件上的记号')
.replace(/记录来源/g, '记下它从哪里来')
.replace(/来源标签/g, '物件上的记号')
.replace(/来源/g, '来处')
}
function basePage(chapter, number, type, extra = {}) {
const packageRoot = chapterPackageRoot(chapter.chapterNumber)
const stablePageId = pageId(chapter.chapterId, number)
return {
pageId: stablePageId,
pageNumber: number,
type,
...resolveComicPageLayout(stablePageId, type),
hotspotGuideOutside: type === 'event',
year: chapter.year,
location: chapter.location,
sceneAlt: chapter.sceneAlt,
// The art-specific path is intentionally only a declaration for the next
// illustration pass. Until that file exists, the reviewed era scene keeps
// the text game usable and the page engine testable.
illustrationAsset:
`/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`,
fallbackAsset: chapter.sceneAsset,
...extra,
}
}
function pageAudioDeclaration(source = {}) {
const declaration = {
audioCueIds: Array.isArray(source.audioCueIds)
? [...source.audioCueIds]
: [],
}
// Cue IDs are production provenance, not complete page narration. C01 uses
// its two dedicated full-page player packages; C02-C15 may expose audio only
// through the approved remote full-page manifest and shared player.
for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) {
if (Object.prototype.hasOwnProperty.call(source, field)) {
declaration[field] = String(source[field] || '')
}
}
if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) {
const durationSeconds = Number(source.audioDurationSeconds)
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
throw new Error('page audio duration must be a non-negative number')
}
declaration.audioDurationSeconds = durationSeconds
}
return declaration
}
function chapterFourAudioDeclaration(pageNumber) {
return pageAudioDeclaration({
audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[
pageId(COMIC_CHAPTER_ID, pageNumber)
],
})
}
function buildChapterFourPages(chapter) {
const eventCaptions = [
{
caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。',
tapPrompt: '点画中的赵建国,听听这一桌的起哄。',
question: '拿饭量证明能干,这样妥当吗?',
position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 },
assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg',
},
{
caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。',
tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。',
question: '已经吃撑还不说、马上抬重物,妥当吗?',
position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 },
assetName: 'S01-C04-P04-H14-belt-table-v1.jpg',
},
{
caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”',
tapPrompt: '点画中的小唐,听清他究竟在提醒什么。',
question: '提醒“别又急又撑”,等于主食一口不能吃吗?',
position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 },
assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg',
},
{
caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”',
tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。',
question: '先拉凳、递水,再问清不适,这样更稳妥吗?',
position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 },
assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg',
},
]
const pages = [
basePage(chapter, 1, 'cover', {
eyebrow: `第04回 · ${chapter.year}`,
headline: chapter.title,
caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。',
primaryAction: '翻开这一回',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`,
...chapterFourAudioDeclaration(1),
}),
basePage(chapter, 2, 'ensemble', {
eyebrow: `${chapter.year} · ${chapter.location}`,
headline: '食堂里又比起了饭量',
caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。',
secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。',
primaryAction: '看看第三碗饭',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`,
...chapterFourAudioDeclaration(2),
}),
]
;(chapter.events || []).forEach((event, index) => {
const person = findPerson(chapter, event.actorInstanceId)
const copy = eventCaptions[index] || {}
pages.push(basePage(chapter, index + 3, 'event', {
eventId: event.hotspotId,
actorInstanceId: event.actorInstanceId,
actorName: event.actorName,
actorPortrait: event.actorPortrait || (person ? person.portrait : ''),
actorPosition: copy.position || (person ? person.position : null),
actorHotspot: actorHotspot(copy.position || person),
question: copy.question || event.question,
headline: event.label,
caption: copy.caption || event.actionDescription,
resolvedCaption: `这一页记下:${event.actionAdvice}`,
tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`,
shortDialogue: event.speech,
illustrationAsset: copy.assetName
? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}`
: '',
...chapterFourAudioDeclaration(index + 3),
}))
})
pages.push(
basePage(chapter, 7, 'emotion', {
emotionMomentId: chapter.emotionMoment.emotionMomentId,
headline: chapter.emotionMoment.title,
caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。',
prompt: chapter.emotionMoment.prompt,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`,
...chapterFourAudioDeclaration(7),
}),
basePage(chapter, 8, 'memory', {
headline: '这一页,收进桂香岁月',
memoryCard: {
cardId: 'S01-C04-MC01',
characterName: chapter.emotionMoment.character.name,
eraLine: `${chapter.year} · ${chapter.location}`,
tableEcho: chapter.emotionMoment.tableEcho,
lifeAction: chapter.events[1].actionAdvice,
familyLine: '下回我嘴硬时,先给我拉把凳子。',
},
caption: chapter.cliffhanger,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`,
...chapterFourAudioDeclaration(8),
}),
)
return pages
}
function buildChapterOnePages(chapter) {
const eventOverlays = [
{
eventId: 'S01-H01',
caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。',
question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?',
shortDialogue: '不扫,就没有我的座了?',
tapPrompt: '点点小满,看看赵伯为什么停住了。',
hotspot: { x: 46, y: 6, w: 36, h: 88 },
assetName: 'S01-C01-P03-H01-scan-only-v1.jpg',
audioCueIds: [
'S01-C01-MS002-ATTR',
'S01-C01-MS002',
'S01-C01-MS003',
'S01-C01-MS004',
'S01-C01-MS005',
'S01-C01-MS006',
],
},
{
eventId: 'S01-H02',
caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”',
question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?',
shortDialogue: '桌上可不能显得空。',
tapPrompt: '点点赵伯,听听他为什么还想加菜。',
hotspot: { x: 16, y: 6, w: 40, h: 90 },
assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg',
audioCueIds: [
'S01-C01-MS007',
'S01-C01-MS008',
'S01-C01-MS009-ATTR',
'S01-C01-MS009',
],
},
{
eventId: 'S01-H03',
caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。',
question: '地上有新压痕,请柬又写着十五桌。您先怎么查?',
shortDialogue: '它不是没来过,是刚走。',
tapPrompt: '点点乐乐,看他在地上发现了什么。',
hotspot: { x: 31, y: 14, w: 49, h: 80 },
assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg',
audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'],
},
{
eventId: 'S01-H04',
caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。',
question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?',
shortDialogue: '十五桌的人……齐了吗?',
tapPrompt: '点点秦师傅,看看他为什么不肯出门。',
hotspot: { x: 51, y: 2, w: 42, h: 96 },
assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg',
audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'],
},
]
const pages = [
basePage(chapter, 1, 'cover', {
eyebrow: `第01回 · ${chapter.year}`,
headline: chapter.title,
caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。',
tapPrompt: '点一下,翻过来听个开头。',
primaryAction: '翻开这一回',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`,
audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'],
}),
basePage(chapter, 2, 'ensemble', {
eyebrow: `${chapter.year} · ${chapter.location}`,
headline: '十五桌,到底去哪儿了?',
caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。',
secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。',
tapPrompt: '先看看,谁在等,谁又躲开了。',
primaryAction: '先看看,大家都在看哪里',
illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
audioCueIds: [
'S01-C01-MS002-ATTR',
'S01-C01-MS002',
'S01-C01-MS003',
'S01-C01-MS004',
],
}),
]
;(chapter.events || []).forEach((event, index) => {
const person = findPerson(chapter, event.actorInstanceId)
const overlay = eventOverlays[index]
if (!overlay || overlay.eventId !== event.hotspotId) {
throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`)
}
pages.push(basePage(chapter, index + 3, 'event', {
eventId: event.hotspotId,
actorInstanceId: event.actorInstanceId,
actorName: event.actorName,
actorPortrait: event.actorPortrait || (person ? person.portrait : ''),
actorPosition: person ? person.position : null,
actorHotspot: fixedHotspot(overlay.hotspot),
question: overlay.question,
headline: event.label,
caption: overlay.caption,
resolvedCaption: `这一页记下:${event.actionAdvice}`,
tapPrompt: overlay.tapPrompt,
shortDialogue: overlay.shortDialogue,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`,
audioCueIds: overlay.audioCueIds,
}))
})
pages.push(
basePage(chapter, 7, 'emotion', {
emotionMomentId: chapter.emotionMoment.emotionMomentId,
headline: chapter.emotionMoment.title,
caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。',
prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`,
actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }),
audioCueIds: ['S01-C01-TE900'],
}),
basePage(chapter, 8, 'memory', {
headline: '这一页,收进桂香岁月',
memoryCard: {
cardId: 'S01-C01-MC01',
characterName: '赵建国',
eraLine: `${chapter.year} · ${chapter.location}`,
tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。',
lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”',
familyLine: '下回别急着替我安排,先叫我一起商量。',
eraObject: '手写请柬与十五号旧铜牌',
},
caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。',
tapPrompt: '锅盖这一响,接着往下看。',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`,
audioCueIds: ['S01-C01-MS013'],
}),
)
return pages
}
function buildChapterTwoPages(chapter) {
const eventOverlays = [
{
eventId: 'S01-H05',
caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”',
question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?',
shortDialogue: '名字差一个,来路就差远了。',
tapPrompt: '点画中的林秀兰,看她把票名说清。',
hotspot: { x: 33, y: 1, w: 52, h: 96 },
assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg',
audioCueIds: ['S01-C02-MS007'],
},
{
eventId: 'S01-H06',
caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。',
question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?',
shortDialogue: '我这不是先把自己认回来嘛。',
tapPrompt: '点画中的赵伯,陪他再认一遍。',
hotspot: { x: 6, y: 1, w: 51, h: 97 },
assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg',
audioCueIds: [
'S01-C02-MS002-ATTR',
'S01-C02-MS002',
'S01-C02-MS003',
'S01-C02-MS004',
'S01-C02-MS005',
'S01-C02-MS006',
],
},
{
eventId: 'S01-H07',
caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。',
question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?',
shortDialogue: '我先记“待核对”,不写“就是”。',
tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。',
hotspot: { x: 25, y: 8, w: 55, h: 89 },
assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg',
audioCueIds: [
'S01-C02-MS008',
'S01-C02-MS009',
'S01-C02-MS010',
],
},
{
eventId: 'S01-H08',
caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。',
question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?',
shortDialogue: '证据会说话,可别逼它一次把所有话都说完。',
tapPrompt: '点画中的林秀兰,看她怎样留证。',
hotspot: { x: 31, y: 0, w: 58, h: 98 },
assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg',
audioCueIds: ['S01-C02-MS011'],
},
]
const pages = [
basePage(chapter, 1, 'cover', {
eyebrow: `第02回 · ${chapter.year}`,
headline: chapter.title,
caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。',
primaryAction: '翻开这一回',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`,
audioCueIds: ['S01-C02-MT000'],
}),
basePage(chapter, 2, 'ensemble', {
eyebrow: `${chapter.year} · ${chapter.location}`,
headline: '三个人,三种线索',
caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。',
secondaryCaption: '',
primaryAction: '先看看,大家各自在看什么',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`,
audioCueIds: ['S01-C02-MS001'],
}),
]
;(chapter.events || []).forEach((event, index) => {
const person = findPerson(chapter, event.actorInstanceId)
const overlay = eventOverlays[index]
if (!overlay || overlay.eventId !== event.hotspotId) {
throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`)
}
pages.push(basePage(chapter, index + 3, 'event', {
eventId: event.hotspotId,
actorInstanceId: event.actorInstanceId,
actorName: event.actorName,
actorPortrait: event.actorPortrait || (person ? person.portrait : ''),
actorPosition: person ? person.position : null,
actorHotspot: fixedHotspot(overlay.hotspot),
question: overlay.question,
headline: event.label,
caption: overlay.caption,
resolvedCaption: `这一页记下:${event.actionAdvice}`,
tapPrompt: overlay.tapPrompt,
shortDialogue: overlay.shortDialogue,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`,
audioCueIds: overlay.audioCueIds,
}))
})
pages.push(
basePage(chapter, 7, 'emotion', {
emotionMomentId: chapter.emotionMoment.emotionMomentId,
headline: chapter.emotionMoment.title,
caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。',
prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`,
actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }),
audioCueIds: ['S01-C02-TE900'],
}),
basePage(chapter, 8, 'memory', {
headline: '这一页,收进桂香岁月',
memoryCard: {
cardId: 'S01-C02-MC01',
characterName: '林秀兰',
eraLine: `${chapter.year} · ${chapter.location}`,
tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。',
lifeAction: '保留近照和尺寸,等待旧桌板出现。',
familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。',
eraObject: '十五号铜牌与两枚破损饭菜票',
},
caption: chapter.cliffhanger,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`,
audioCueIds: ['S01-C02-MS012'],
}),
)
return pages
}
function buildChapterThreePages(chapter) {
const eventOverlays = [
{
eventId: 'S01-H09',
caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。',
question: '没有清洁双手就直接抓食物,这样妥当吗?',
shortDialogue: '差点把车间也吃进去了,我先去洗。',
tapPrompt: '点画中的老吕,看看他从车间带回来的手。',
hotspot: { x: 18, y: 1, w: 56, h: 97 },
artAspectRatio: 16 / 9,
assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg',
audioCueIds: [
'S01-C03-MS006',
'S01-C03-MS007-ATTR',
'S01-C03-MS007',
'S01-C03-MS008',
],
},
{
eventId: 'S01-H10',
caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。',
question: '一个人不提醒同伴就突然起身,这样妥当吗?',
shortDialogue: '这声早半拍,我的汤就保住了。',
tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。',
hotspot: { x: 14, y: 1, w: 78, h: 97 },
artAspectRatio: 16 / 9,
assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg',
audioCueIds: ['S01-C03-MS010'],
},
{
eventId: 'S01-H11',
caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。',
question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?',
shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”',
tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。',
hotspot: { x: 29, y: 1, w: 49, h: 97 },
assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg',
audioCueIds: [
'S01-C03-MS003',
'S01-C03-MS004',
'S01-C03-MS011',
],
},
{
eventId: 'S01-H12',
caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”',
question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?',
shortDialogue: '账能重算,回来吃饭的人不能漏。',
tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。',
hotspot: { x: 29, y: 0, w: 56, h: 98 },
artAspectRatio: 1280 / 540,
assetName: 'S01-C03-P06-H12-two-clips-v1.jpg',
audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'],
},
]
const pages = [
basePage(chapter, 1, 'cover', {
eyebrow: `第03回 · ${chapter.year}`,
headline: chapter.title,
caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。',
primaryAction: '翻开这一回',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`,
audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'],
}),
basePage(chapter, 2, 'ensemble', {
eyebrow: `${chapter.year} · ${chapter.location}`,
headline: '一顿午饭,同时忙着六件事',
caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。',
secondaryCaption: '',
primaryAction: '先看看,谁在忙什么',
artAspectRatio: 16 / 9,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`,
audioCueIds: [
'S01-C03-MS002-ATTR',
'S01-C03-MS002',
'S01-C03-MS003',
],
}),
]
;(chapter.events || []).forEach((event, index) => {
const person = findPerson(chapter, event.actorInstanceId)
const overlay = eventOverlays[index]
if (!overlay || overlay.eventId !== event.hotspotId) {
throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`)
}
pages.push(basePage(chapter, index + 3, 'event', {
eventId: event.hotspotId,
actorInstanceId: event.actorInstanceId,
actorName: event.actorName,
actorPortrait: event.actorPortrait || (person ? person.portrait : ''),
actorPosition: person ? person.position : null,
actorHotspot: fixedHotspot(overlay.hotspot),
question: overlay.question,
headline: event.label,
caption: overlay.caption,
resolvedCaption: `这一页记下:${event.actionAdvice}`,
tapPrompt: overlay.tapPrompt,
shortDialogue: overlay.shortDialogue,
...(overlay.artAspectRatio
? { artAspectRatio: overlay.artAspectRatio }
: {}),
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`,
audioCueIds: overlay.audioCueIds,
}))
})
pages.push(
basePage(chapter, 7, 'emotion', {
emotionMomentId: chapter.emotionMoment.emotionMomentId,
headline: chapter.emotionMoment.title,
caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。',
prompt: '你想怎样给这个不起眼的小唐留一点位置?',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`,
actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }),
audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'],
}),
basePage(chapter, 8, 'memory', {
headline: '这一页,收进桂香岁月',
memoryCard: {
cardId: 'S01-C03-MC01',
characterName: '唐守安',
eraLine: `${chapter.year} · ${chapter.location}`,
tableEcho: '看见一个人,不只看他能干多少,也问他累不累。',
lifeAction: '进食前按条件把手清洁并擦干',
familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?',
eraObject: '饭票与搪瓷饭盒',
},
caption: chapter.cliffhanger,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`,
audioCueIds: [
'S01-C03-MS013',
'S01-C03-MS014',
'S01-C03-MS015',
],
}),
)
return pages
}
function buildChapterFivePages(chapter) {
const eventOverlays = [
{
eventId: 'S01-H17',
caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”',
shortDialogue: '算了,空一顿也能顶。',
tapPrompt: '点画中的老吕,看看他为什么又想转身。',
hotspot: { x: 20, y: 1, w: 53, h: 97 },
assetName: 'S01-C05-P03-H17-empty-window-v1.jpg',
audioCueIds: [
'S01-C05-MS002-ATTR',
'S01-C05-MS002',
'S01-C05-MS003-ATTR',
'S01-C05-MS003',
],
},
{
eventId: 'S01-H18',
caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”',
shortDialogue: '先数人,别数功劳。',
tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。',
hotspot: { x: 21, y: 1, w: 56, h: 97 },
assetName: 'S01-C05-P04-H18-half-bun-v1.jpg',
audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'],
},
{
eventId: 'S01-H19',
caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。',
shortDialogue: '旧菜不赌,重新做。',
tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。',
hotspot: { x: 29, y: 0, w: 55, h: 98 },
assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg',
audioCueIds: [
'S01-C05-MS008',
'S01-C05-MS009',
'S01-C05-MS010',
'S01-C05-MS012',
],
},
{
eventId: 'S01-H20',
caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。',
shortDialogue: '来晚的人,也得有地方坐。',
tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。',
hotspot: { x: 47, y: 1, w: 43, h: 97 },
assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg',
audioCueIds: [
'S01-C05-MS011',
'S01-C05-MS013',
'S01-C05-MS014',
'S01-C05-MS015',
],
},
]
const pages = [
basePage(chapter, 1, 'cover', {
eyebrow: `第05回 · ${chapter.year}`,
headline: chapter.title,
caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。',
primaryAction: '翻开这一回',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`,
audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'],
}),
basePage(chapter, 2, 'ensemble', {
eyebrow: `${chapter.year} · ${chapter.location}`,
headline: '人报过了,饭却没有留下',
caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。',
secondaryCaption: '',
primaryAction: '先看看,谁正准备走,谁正把人留下',
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`,
audioCueIds: [
'S01-C05-MS002-ATTR',
'S01-C05-MS002',
'S01-C05-MS003-ATTR',
'S01-C05-MS003',
],
}),
]
;(chapter.events || []).forEach((event, index) => {
const person = findPerson(chapter, event.actorInstanceId)
const overlay = eventOverlays[index]
if (!overlay || overlay.eventId !== event.hotspotId) {
throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`)
}
pages.push(basePage(chapter, index + 3, 'event', {
eventId: event.hotspotId,
actorInstanceId: event.actorInstanceId,
actorName: event.actorName,
actorPortrait: event.actorPortrait || (person ? person.portrait : ''),
actorPosition: person ? person.position : null,
actorHotspot: fixedHotspot(overlay.hotspot),
question: event.question,
evidence: playerFacingCopy(event.evidence),
headline: event.label,
caption: overlay.caption,
resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`,
tapPrompt: overlay.tapPrompt,
shortDialogue: overlay.shortDialogue,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`,
audioCueIds: overlay.audioCueIds,
}))
})
pages.push(
basePage(chapter, 7, 'emotion', {
emotionMomentId: chapter.emotionMoment.emotionMomentId,
headline: chapter.emotionMoment.title,
caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。',
prompt: chapter.emotionMoment.prompt,
actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }),
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`,
audioCueIds: ['S01-C05-TE900'],
}),
basePage(chapter, 8, 'memory', {
headline: '这一页,收进桂香岁月',
memoryCard: {
cardId: 'S01-C05-MC01',
characterName: '秦志成',
eraLine: `${chapter.year} · ${chapter.location}`,
tableEcho: '有人为晚归的人留了一盏灯。',
lifeAction: '尽快说明情况,按单位安排和个人需要解决进食',
familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?',
eraObject: '第十五桌木牌与留饭灯',
},
caption: chapter.cliffhanger,
illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`,
audioCueIds: ['S01-C05-MS016'],
}),
)
return pages
}
function reviewedProductionAsset(chapter, productionPage) {
return [
'',
chapterPackageRoot(chapter.chapterNumber),
'assets',
'comic',
chapter.chapterId.toLowerCase(),
productionPage.assetName,
].join('/')
}
function buildReviewedProductionChapterPages(chapter) {
const productionChapter = productionComicChapters[chapter.chapterId]
const productionPages = productionChapter && productionChapter.pages
if (!Array.isArray(productionPages) || productionPages.length !== 8) {
throw new Error(`${chapter.chapterId} needs eight reviewed production pages`)
}
const [cover, ensemble, ...remainingPages] = productionPages
const eventPages = remainingPages.slice(0, 4)
const emotionPage = remainingPages[4]
const memoryPage = remainingPages[5]
const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId)
const isSeasonClose = Boolean(
memoryLayoutException
&& memoryLayoutException.behavior === 'season-close-detail-inset'
)
const reviewedMemoryCard = memoryCards.find(
(card) => card.chapterId === chapter.chapterId,
)
if (!reviewedMemoryCard) {
throw new Error(`${chapter.chapterId} is missing its reviewed memory card`)
}
const pages = [
basePage(chapter, 1, 'cover', {
eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`,
headline: chapter.title,
caption: cover.caption,
primaryAction: cover.interactionPrompt || '翻开这一回',
illustrationAsset: reviewedProductionAsset(chapter, cover),
...pageAudioDeclaration(cover),
}),
basePage(chapter, 2, 'ensemble', {
eyebrow: `${chapter.year} · ${chapter.location}`,
headline: chapter.title,
caption: ensemble.caption,
secondaryCaption: '',
primaryAction: ensemble.interactionPrompt || '先看看画中人物',
illustrationAsset: reviewedProductionAsset(chapter, ensemble),
...pageAudioDeclaration(ensemble),
}),
]
;(chapter.events || []).forEach((event, index) => {
const productionPage = eventPages[index]
const person = findPerson(chapter, event.actorInstanceId)
const layoutException = productionPage
? getComicPageLayoutException(productionPage.pageId)
: null
if (
!productionPage
|| productionPage.pageId !== pageId(chapter.chapterId, index + 3)
|| productionPage.eventId !== event.hotspotId
|| !productionPage.hotspot
) {
throw new Error(
`${chapter.chapterId} production page does not match ${event.hotspotId}`,
)
}
pages.push(basePage(chapter, index + 3, 'event', {
eventId: event.hotspotId,
actorInstanceId: event.actorInstanceId,
actorName: event.actorName,
actorPortrait: event.actorPortrait || (person ? person.portrait : ''),
actorPosition: person ? person.position : null,
// Page-specific hit-area changes come only from the explicit exception
// registry. Visible guidance for every event is part of the shared
// actor-event template and always stays outside the artwork.
actorHotspot: fixedHotspot(
layoutException && layoutException.actorHotspot
? layoutException.actorHotspot
: productionPage.hotspot,
),
question: event.question,
headline: event.label,
caption: productionPage.caption,
secondaryCaption: productionPage.secondaryCaption || '',
outsideActionLabel: productionPage.outsideActionLabel || '',
artTapHint: productionPage.artTapHint || '',
resolvedCaption: `这一页记下:${event.actionAdvice}`,
tapPrompt: `点画中的${event.actorName},看看这一刻。`,
shortDialogue: productionPage.shortDialogue || event.speech,
illustrationAsset: reviewedProductionAsset(chapter, productionPage),
...(productionPage.programEvidenceInset
? {
programEvidenceInset: fixedProgramEvidenceInset(
productionPage.programEvidenceInset,
),
}
: {}),
...pageAudioDeclaration(productionPage),
}))
})
if (
emotionPage.emotionMomentId
!== chapter.emotionMoment.emotionMomentId
) {
throw new Error(`${chapter.chapterId} emotion page is not locked to source`)
}
pages.push(
basePage(chapter, 7, 'emotion', {
emotionMomentId: chapter.emotionMoment.emotionMomentId,
headline: chapter.emotionMoment.title,
caption: emotionPage.caption,
prompt: chapter.emotionMoment.prompt,
actorHotspot: emotionPage.hotspot
? fixedHotspot(emotionPage.hotspot)
: null,
illustrationAsset: reviewedProductionAsset(chapter, emotionPage),
...pageAudioDeclaration(emotionPage),
}),
basePage(chapter, 8, 'memory', {
headline: '这一页,收进桂香岁月',
memoryCard: {
cardId: reviewedMemoryCard.cardId,
characterName: reviewedMemoryCard.characterName,
eraLine: reviewedMemoryCard.eraLine,
tableEcho: reviewedMemoryCard.tableEcho,
lifeAction: reviewedMemoryCard.lifeAction,
familyLine: reviewedMemoryCard.familyLine,
eraObject: reviewedMemoryCard.eraObject,
...(Array.isArray(memoryPage.memoryDisplayLines)
&& memoryPage.memoryDisplayLines.length
? {
displayLines: memoryPage.memoryDisplayLines.map(
(line) => String(line),
),
}
: {}),
},
caption: memoryPage.cliffhanger || chapter.cliffhanger,
illustrationAsset: reviewedProductionAsset(chapter, memoryPage),
...pageAudioDeclaration(memoryPage),
...(isSeasonClose
? {
isSeasonClose: true,
seasonCloseCaption: memoryPage.caption,
seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger,
detailInset: fixedDetailInset(memoryPage.programDetailInset),
}
: {}),
}),
)
return pages
}
function chapterPeopleLine(chapter) {
const names = (chapter.people || [])
.map((person) => person.name)
.filter(Boolean)
.filter((name, index, allNames) => allNames.indexOf(name) === index)
.slice(0, 5)
if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。'
return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。`
}
function appendGenericInteractivePages(pages, chapter) {
;(chapter.events || []).forEach((event, index) => {
const person = findPerson(chapter, event.actorInstanceId)
pages.push(basePage(chapter, index + 3, 'event', {
eventId: event.hotspotId,
actorInstanceId: event.actorInstanceId,
actorName: event.actorName,
actorPortrait: event.actorPortrait || (person ? person.portrait : ''),
actorPosition: person ? person.position : null,
actorHotspot: actorHotspot(person),
question: event.question,
headline: event.label,
caption: event.actionDescription,
resolvedCaption: `这一页记下:${event.actionAdvice}`,
tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`,
shortDialogue: event.speech,
}))
})
const emotionMoment = chapter.emotionMoment
pages.push(
basePage(chapter, 7, 'emotion', {
emotionMomentId: emotionMoment.emotionMomentId,
headline: emotionMoment.title,
caption: emotionMoment.sceneText,
prompt: emotionMoment.prompt,
}),
basePage(chapter, 8, 'memory', {
headline: '这一页,收进桂香岁月',
memoryCard: {
cardId: `${chapter.chapterId}-MC01`,
characterName: emotionMoment.character.name,
eraLine: `${chapter.year} · ${chapter.location}`,
tableEcho: emotionMoment.tableEcho,
lifeAction: chapter.events[0].actionAdvice,
familyLine: `回家聊一聊:${emotionMoment.prompt}`,
},
caption: chapter.cliffhanger,
}),
)
}
function buildGenericChapterPages(chapter) {
const pages = [
basePage(chapter, 1, 'cover', {
eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`,
headline: chapter.title,
caption: chapter.narration,
primaryAction: '翻开这一回',
}),
basePage(chapter, 2, 'ensemble', {
eyebrow: `${chapter.year} · ${chapter.location}`,
headline: chapter.title,
caption: chapter.narration,
secondaryCaption: chapterPeopleLine(chapter),
primaryAction: '看看画中人物',
}),
]
appendGenericInteractivePages(pages, chapter)
return pages
}
function buildComicPageModel(chapter, chapterNumber) {
if (!chapter || !chapter.chapterId) {
return {
mode: 'legacy',
chapterId: '',
chapterNumber: Number(chapterNumber) || 1,
pageSequence: [],
firstPageId: '',
lastPageId: '',
}
}
let pageSequence = []
let prototypeScope = ''
if (chapter.chapterId === COMIC_CHAPTER_ID) {
pageSequence = buildChapterFourPages(chapter)
prototypeScope = 'full-eight-pages'
} else if (chapter.chapterId === CHAPTER_ONE_ID) {
pageSequence = buildChapterOnePages(chapter)
prototypeScope = 'full-eight-pages'
} else if (chapter.chapterId === CHAPTER_TWO_ID) {
pageSequence = buildChapterTwoPages(chapter)
prototypeScope = 'full-eight-pages'
} else if (chapter.chapterId === CHAPTER_THREE_ID) {
pageSequence = buildChapterThreePages(chapter)
prototypeScope = 'full-eight-pages'
} else if (chapter.chapterId === CHAPTER_FIVE_ID) {
pageSequence = buildChapterFivePages(chapter)
prototypeScope = 'full-eight-pages'
} else if (productionComicChapters[chapter.chapterId]) {
pageSequence = buildReviewedProductionChapterPages(chapter)
prototypeScope = 'full-eight-pages'
} else {
pageSequence = buildGenericChapterPages(chapter)
prototypeScope = 'full-eight-pages'
}
pageSequence = pageSequence.map(attachFixedDetailInset)
pageSequence = attachPlayableVisuals(pageSequence, chapter)
return {
mode: 'comic',
chapterId: chapter.chapterId,
chapterNumber: Number(chapterNumber) || chapter.chapterNumber,
presentationMode: getComicPresentationMode(chapter.chapterId),
pageSequence,
firstPageId: pageSequence[0].pageId,
lastPageId: pageSequence[pageSequence.length - 1].pageId,
prototypeScope,
}
}
function findPageIndex(model, requestedPageId) {
if (!model || !Array.isArray(model.pageSequence)) return -1
return model.pageSequence.findIndex(
(page) => page.pageId === requestedPageId,
)
}
function getResumePageId(
model,
savedPageId,
completedIds = [],
chapterFinished = false,
) {
if (!model || model.mode !== 'comic' || !model.pageSequence.length) {
return ''
}
if (chapterFinished) return model.lastPageId
const eventPages = model.pageSequence.filter((page) => page.type === 'event')
const savedPageIndex = findPageIndex(model, savedPageId)
if (!eventPages.length) {
return savedPageIndex >= 0 ? savedPageId : model.firstPageId
}
const validEventIds = new Set(eventPages.map((page) => page.eventId))
const completed = new Set(
(Array.isArray(completedIds) ? completedIds : []).filter(
(eventId) => validEventIds.has(eventId),
),
)
const firstIncomplete = eventPages.find(
(page) => !completed.has(page.eventId),
)
if (firstIncomplete) {
const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId)
if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) {
return savedPageId
}
if (savedPageIndex > firstIncompleteIndex) {
return firstIncomplete.pageId
}
return completed.size ? firstIncomplete.pageId : model.firstPageId
}
const emotionPage = model.pageSequence.find(
(page) => page.type === 'emotion',
)
if (!emotionPage) return model.lastPageId
const emotionPageIndex = findPageIndex(model, emotionPage.pageId)
if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) {
return savedPageId
}
return emotionPage.pageId
}
module.exports = {
buildComicPageModel,
findPageIndex,
getResumePageId,
pageAudioDeclaration,
}