1250 lines
49 KiB
JavaScript
1250 lines
49 KiB
JavaScript
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,
|
||
}
|