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

2303 lines
72 KiB
JavaScript

const season = require('../../../data/season')
const {
getProgress,
saveProgress,
getSettings,
saveSettings,
getAudioProgress,
saveAudioProgress,
} = require('../../../utils/storage')
const {
getChapterLayoutMetrics,
} = require('../../../utils/layout')
const {
getComicArtStageStyle,
} = require('../../../utils/comicLayout')
const {
addMemoryCard,
} = require('../../../utils/memoryCollection')
const {
chapterRoute,
normalizeChapterNumber,
} = require('../../../utils/chapterRoute')
const {
buildComicPageModel,
} = require('./chapterPages')
const {
applyComicReaderAction,
buildComicReaderState,
mergeComicReaderProgress,
readComicReaderProgress,
} = require('../../utils/comicReaderState')
const {
releaseAssets,
} = require('../../data/releaseAssetManifest')
const {
getApprovedRemotePageAudio,
} = require('../../data/remotePageAudioManifest')
const assetReleaseConfig = require('../../data/assetReleaseConfig')
const {
createAssetPlatformFacade,
createAssetManager,
} = require('../../utils/assetManager')
const {
IMAGE_UNAVAILABLE_MESSAGE,
buildComicImageState,
buildReviewedAudioState,
getComicFocusImageStyle,
getComicPageImageMode,
getComicPageImageStyle,
} = require('../../utils/comicPageModel')
const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg'
const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg'
const MEMORY_SHARE_SOURCE = 'chapter-memory'
const STORY_FIRST_PRESENTATION = 'front-back-comic-v1'
// C01 full-page tracks live behind real player pages in two sibling
// subpackages. The reader may navigate to those pages, but must never read a
// sibling package's MP3 path directly (WeChat forbids cross-package assets).
const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({
'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }),
'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }),
'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }),
'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }),
'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }),
'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }),
'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }),
'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }),
})
const CHAPTER_ONE_EVENT_COPY = Object.freeze({
'S01-H01': Object.freeze({
question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?',
actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。',
evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。',
retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。',
reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。',
actionAdvice: '扫码、纸名单、有人可问,三样都留着。',
choiceOptions: Object.freeze([
Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }),
Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }),
]),
}),
'S01-H02': Object.freeze({
question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?',
actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。',
evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。',
retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。',
reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。',
actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。',
choiceOptions: Object.freeze([
Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }),
Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }),
]),
}),
'S01-H03': Object.freeze({
question: '地上有新压痕,请柬又写着十五桌。您先怎么查?',
actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。',
evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。',
retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。',
reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。',
actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。',
choiceOptions: Object.freeze([
Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }),
Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }),
]),
}),
'S01-H04': Object.freeze({
question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?',
actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。',
evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。',
retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。',
reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。',
actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。',
choiceOptions: Object.freeze([
Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }),
Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }),
]),
}),
})
const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([
Object.freeze({
choiceId: 'S01-EM01-A',
text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”',
}),
Object.freeze({
choiceId: 'S01-EM01-B',
text: '先拉一把普通椅子,再问他愿不愿意坐下等。',
}),
])
// The internal values stay locked to the reviewed season data. Only the
// player-facing question and the two ordinary actions change here, so the
// interaction reads like a conversation about the scene instead of a quiz.
const STORY_FIRST_EVENT_COPY = Object.freeze({
'S01-H05': {
question: '展签把厂内饭菜票写成全国粮票,您先怎么办?',
choiceOptions: [
{ value: 'safer', label: '照着展签,继续说是全国粮票' },
{ value: 'caution', label: '先看票面,再把展签改准' },
],
},
'S01-H06': {
question: '赵伯说缺口碗是他的,您先怎么认?',
choiceOptions: [
{ value: 'safer', label: '听他说完,马上写上名字' },
{ value: 'caution', label: '翻过照片,再对一遍旧账' },
],
},
'S01-H07': {
question: '合影里露出半张脸和几件旧物,您先记什么?',
choiceOptions: [
{ value: 'safer', label: '把布包和桌沿都记下来' },
{ value: 'caution', label: '只看半张脸,就把人认定' },
],
},
'S01-H08': {
question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?',
choiceOptions: [
{ value: 'safer', label: '拍下漆痕孔位,留着再核对' },
{ value: 'caution', label: '只看一处红漆,就定了来历' },
],
},
'S01-H09': {
question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?',
choiceOptions: [
{ value: 'safer', label: '赶时间,先拿馒头再说' },
{ value: 'caution', label: '先把手洗净擦干,再吃饭' },
],
},
'S01-H10': {
question: '同坐一条长凳,有人要起身。您会怎么做?',
choiceOptions: [
{ value: 'safer', label: '不用招呼,直接站起来' },
{ value: 'caution', label: '先喊坐稳,再慢慢起身' },
],
},
'S01-H11': {
question: '看见当年工人吃大盆主食,您会怎么说?',
choiceOptions: [
{ value: 'safer', label: '按今天的吃法,笑他们饭多' },
{ value: 'caution', label: '先看当年供应和下午的重活' },
],
},
'S01-H12': {
question: '饭菜票和晚班人数单到了手里,您怎么收?',
choiceOptions: [
{ value: 'safer', label: '两种凭据分开夹、分开核对' },
{ value: 'caution', label: '两种票单混在一起收' },
],
},
'S01-H13': {
question: '工友又起哄比谁先吃第三碗,您会怎么接?',
choiceOptions: [
{ value: 'safer', label: '跟着起哄,比谁先吃第三碗' },
{ value: 'caution', label: '不跟着比,先问自己还饿不饿' },
],
},
'S01-H14': {
question: '赵建国已经吃撑,还要马上抬货。您先怎么办?',
choiceOptions: [
{ value: 'safer', label: '嘴上说没事,马上去抬货' },
{ value: 'caution', label: '先停下添饭,说清哪里难受' },
],
},
'S01-H15': {
question: '小唐说“别又急又撑”,这句话该怎么听?',
choiceOptions: [
{ value: 'safer', label: '听成主食一口都不能吃' },
{ value: 'caution', label: '听成别急别撑,再看自己需要' },
],
},
'S01-H16': {
question: '同伴扶住桌沿,您先怎么给他留面子?',
choiceOptions: [
{ value: 'safer', label: '拉把凳子,先问要不要歇' },
{ value: 'caution', label: '继续拿他起哄,催他干活' },
],
},
'S01-H17': {
question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?',
choiceOptions: [
{ value: 'safer', label: '什么也不说,空着肚子硬扛' },
{ value: 'caution', label: '告诉带班人,先吃饭休息' },
],
},
'S01-H18': {
question: '只剩半个馒头,几位晚班工友怎么分?',
choiceOptions: [
{ value: 'safer', label: '都留给最能干的那个人' },
{ value: 'caution', label: '先数清人数,再一起商量' },
],
},
'S01-H19': {
question: '午间熟菜放了多久说不清,秦师傅怎么做?',
choiceOptions: [
{ value: 'safer', label: '拿另放妥当的白菜和面现做' },
{ value: 'caution', label: '把放了多久不清楚的饭再热' },
],
},
'S01-H20': {
question: '晚班的人终于回来了,这张桌该怎么摆?',
choiceOptions: [
{ value: 'safer', label: '把桌摆好,让晚班人坐下吃' },
{ value: 'caution', label: '让来晚的人端碗站在门口' },
],
},
'S01-H21': {
question: '秦师傅签了经营责任书,旧桌就归他了吗?',
choiceOptions: [
{ value: 'safer', label: '签完字,就当旧桌归自己' },
{ value: 'caution', label: '对着合同和清单逐项核对' },
],
},
'S01-H22': {
question: '老工友还递饭票,您会怎么帮他?',
choiceOptions: [
{ value: 'safer', label: '笑他没跟上,让他自己琢磨' },
{ value: 'caution', label: '说清付款办法,陪他办一次' },
],
},
'S01-H23': {
question: '饭馆开业再忙,员工这顿饭怎么安排?',
choiceOptions: [
{ value: 'safer', label: '先排好替班,让员工坐下吃饭' },
{ value: 'caution', label: '忙起来,就靠尝菜顶一顿' },
],
},
'S01-H24': {
question: '客人想拿员工桌拼大桌,秦师傅怎么安排?',
choiceOptions: [
{ value: 'safer', label: '另给客人拼桌,员工桌照留' },
{ value: 'caution', label: '先撤员工桌,等忙完再说' },
],
},
'S01-H25': {
question: '孩子已经说饱了,碗里还有饭。您会怎么做?',
choiceOptions: [
{ value: 'safer', label: '再撑也得把碗吃见底' },
{ value: 'caution', label: '说饱就停,下回先少盛' },
],
},
'S01-H26': {
question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?',
choiceOptions: [
{ value: 'safer', label: '不问孩子,拿勺接着添' },
{ value: 'caution', label: '先问还要不要,同意再添' },
],
},
'S01-H27': {
question: '孩子的话还没说完,唐守安已经在看表。您怎么做?',
choiceOptions: [
{ value: 'safer', label: '边看表边问,没听完就走' },
{ value: 'caution', label: '放下表,听孩子把话说完' },
],
},
'S01-H28': {
question: '饭馆忙起来,员工在哪儿吃、什么时候吃?',
choiceOptions: [
{ value: 'safer', label: '清出员工桌,轮流坐下吃' },
{ value: 'caution', label: '客人一多,员工就站着吃' },
],
},
'S01-H29': {
question: '菜已经摆满,主家还怕别人说小气。您怎么劝?',
choiceOptions: [
{ value: 'safer', label: '怕人说小气,再添几道菜' },
{ value: 'caution', label: '先看人数份量,真缺再添' },
],
},
'S01-H30': {
question: '剩菜要不要带,主家只怕没面子。您怎么接?',
choiceOptions: [
{ value: 'safer', label: '觉得打包丢脸,剩菜全不要' },
{ value: 'caution', label: '先问哪些能带,再照提示存' },
],
},
'S01-H31': {
question: '套餐只写菜名,主家看不出够几个人。您先问什么?',
choiceOptions: [
{ value: 'safer', label: '只看菜名,马上把套餐定下' },
{ value: 'caution', label: '问清盘量和人数,再决定' },
],
},
'S01-H32': {
question: '婚宴临时加桌,员工的座位怎么办?',
choiceOptions: [
{ value: 'safer', label: '婚宴加桌,先把员工桌挪走' },
{ value: 'caution', label: '先留员工座位,再安排加桌' },
],
},
'S01-H33': {
question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?',
choiceOptions: [
{ value: 'safer', label: '还要开车,也尝一口白酒' },
{ value: 'caution', label: '撤下酒杯,换水再开车' },
],
},
'S01-H34': {
question: '唐守安已经拒酒,这桌人接下来怎么碰杯?',
choiceOptions: [
{ value: 'safer', label: '递上热茶,照样碰杯聊天' },
{ value: 'caution', label: '继续劝酒,非喝一口才算' },
],
},
'S01-H35': {
question: '酒局要开始,赵伯想自己改药。您怎么劝?',
choiceOptions: [
{ value: 'safer', label: '为了酒局,自己把药量改改' },
{ value: 'caution', label: '照原有安排,有疑问问医生药师' },
],
},
'S01-H36': {
question: '赵伯出汗、手抖、反应慢,眼前先做什么?',
choiceOptions: [
{ value: 'safer', label: '先停酒移开杯子,留人陪着' },
{ value: 'caution', label: '还当醉酒,继续劝他喝' },
],
},
'S01-H37': {
question: '炊事员总在传菜口站着吃,怎么给她留顿饭?',
choiceOptions: [
{ value: 'safer', label: '趁传菜空当,站着几口吃完' },
{ value: 'caution', label: '清出座位替班,坐下吃完' },
],
},
'S01-H38': {
question: '赵伯又拿大碗给明远添饭,您怎么接?',
choiceOptions: [
{ value: 'safer', label: '拿大碗添满,夸能吃有本事' },
{ value: 'caution', label: '先问要不要,再按需要盛' },
],
},
'S01-H39': {
question: '订席电话又响,明远这顿冷饭怎么办?',
choiceOptions: [
{ value: 'safer', label: '电话一响,就放下饭继续接' },
{ value: 'caution', label: '安排人替接,留出吃饭时间' },
],
},
'S01-H40': {
question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?',
choiceOptions: [
{ value: 'safer', label: '酒箱照堆,嘴上说留着就行' },
{ value: 'caution', label: '清空桌面替班,让人坐下' },
],
},
'S01-H41': {
question: '“健康、互助”不能只挂墙上,还要落在哪儿?',
choiceOptions: [
{ value: 'safer', label: '只把展板擦亮,服务照旧' },
{ value: 'caution', label: '把座位、纸单和帮忙安排好' },
],
},
'S01-H42': {
question: '旧桌有感情,可还没检查。秦师傅先怎么办?',
choiceOptions: [
{ value: 'safer', label: '舍不得旧桌,直接给客人用' },
{ value: 'caution', label: '先停用检查,再决定怎么留' },
],
},
'S01-H43': {
question: '饭店翻建,材料和行人通道怎么分?',
choiceOptions: [
{ value: 'safer', label: '围好材料区,给人留通道' },
{ value: 'caution', label: '材料靠通道堆,大家绕着走' },
],
},
'S01-H44': {
question: '旧桌不能再用,留下来的物件怎么收?',
choiceOptions: [
{ value: 'safer', label: '登记来源,把桌板铜牌存好' },
{ value: 'caution', label: '签完处置单,旧物随手丢' },
],
},
'S01-H45': {
question: '库房钥匙在手,开柜前先做什么?',
choiceOptions: [
{ value: 'safer', label: '先问同意,再逐件拍照核对' },
{ value: 'caution', label: '拿钥匙就开柜,只看一个孔' },
],
},
'S01-H46': {
question: '饭店用了扫码点餐,老人不熟手机怎么办?',
choiceOptions: [
{ value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' },
{ value: 'caution', label: '只留扫码牌,让老人自己试' },
],
},
'S01-H47': {
question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?',
choiceOptions: [
{ value: 'safer', label: '嫌十四不好听,再加两道菜' },
{ value: 'caution', label: '看人数份量,真不够再添' },
],
},
'S01-H48': {
question: '菜单写着“炖、小份”,您会怎么用这些信息?',
choiceOptions: [
{ value: 'safer', label: '看见炖和小份,就当健康保证' },
{ value: 'caution', label: '把做法份量当信息来选' },
],
},
'S01-H49': {
question: '三双公筷一起伸向乐乐,您先说什么?',
choiceOptions: [
{ value: 'safer', label: '都别停,一起往孩子碗里夹' },
{ value: 'caution', label: '先问想不想吃,同意再夹' },
],
},
'S01-H50': {
question: '大家担心赵伯,座位该怎么安排?',
choiceOptions: [
{ value: 'safer', label: '没问赵伯,就把他挪到侧桌' },
{ value: 'caution', label: '先问想坐哪,再在同桌调整' },
],
},
'S01-H51': {
question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?',
choiceOptions: [
{ value: 'safer', label: '只管孩子少喝,自己照喝甜饮' },
{ value: 'caution', label: '全家一起换,让孩子参与选' },
],
},
'S01-H52': {
question: '小满手里拿着软尺,先量座位还是先问赵伯?',
choiceOptions: [
{ value: 'safer', label: '先问赵伯想坐哪,再搬椅子' },
{ value: 'caution', label: '替他拿主意,直接挪座位' },
],
},
'S01-H53': {
question: '有人想把热汤面叫“祖传降糖面”,您怎么接?',
choiceOptions: [
{ value: 'safer', label: '把老汤面说成祖传降糖菜' },
{ value: 'caution', label: '只讲食材做法,不说能降糖' },
],
},
'S01-H54': {
question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?',
choiceOptions: [
{ value: 'safer', label: '票据铜牌照片一起核对' },
{ value: 'caution', label: '只凭一处红漆就定来历' },
],
},
'S01-H55': {
question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?',
choiceOptions: [
{ value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' },
{ value: 'caution', label: '请唐大夫坐主位,替他说' },
],
},
'S01-H56': {
question: '这桌人饮品各不相同,怎么碰杯?',
choiceOptions: [
{ value: 'safer', label: '各拿自己的饮品,照样碰杯' },
{ value: 'caution', label: '把茶换成酒,再劝一轮' },
],
},
'S01-H57': {
question: '一家人饭量不一样,菜单份量怎么摆?',
choiceOptions: [
{ value: 'safer', label: '摆出三种份量,按人数来选' },
{ value: 'caution', label: '只留一种大份,大家统一点' },
],
},
'S01-H58': {
question: '开席前,桌上的饮品怎么摆?',
choiceOptions: [
{ value: 'safer', label: '先摆白水,其他有人要再拿' },
{ value: 'caution', label: '酒和甜饮先摆满桌' },
],
},
'S01-H59': {
question: '明远的公筷停在半空,下一步怎么做?',
choiceOptions: [
{ value: 'safer', label: '筷子先停住,问孩子还要不要' },
{ value: 'caution', label: '不等开口,直接夹进碗' },
],
},
'S01-H60': {
question: '桌上有剩菜,员工先怎么打包?',
choiceOptions: [
{ value: 'safer', label: '不管是什么,剩菜全打包' },
{ value: 'caution', label: '逐样问清能不能存,再打包' },
],
},
})
function chapterOneChoiceOptions(eventId, fallbackOptions = []) {
const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')]
const fixed = eventCopy && eventCopy.choiceOptions
if (fixed) return fixed.map((option) => ({ ...option }))
return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map(
(option) => ({
value: option.value,
label: option.label,
}),
)
}
function storyFirstChoiceOptions(eventId, fallbackOptions = []) {
if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) {
return chapterOneChoiceOptions(eventId, fallbackOptions)
}
const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')]
if (eventCopy && Array.isArray(eventCopy.choiceOptions)) {
return eventCopy.choiceOptions.map((option) => ({ ...option }))
}
return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map(
(option) => ({
value: option.value,
label: option.value === 'safer'
? '按画里的做法接着办。'
: '先停一停,再看看手边的情况。',
}),
)
}
function storyFirstQuestion(sourceEvent, pageQuestion = '') {
const fixed = CHAPTER_ONE_EVENT_COPY[
String(sourceEvent && sourceEvent.hotspotId || '')
]
if (fixed && fixed.question) return fixed.question
const eventCopy = STORY_FIRST_EVENT_COPY[
String(sourceEvent && sourceEvent.hotspotId || '')
]
if (eventCopy && eventCopy.question) return eventCopy.question
const source = String(
pageQuestion || sourceEvent && sourceEvent.question || '',
)
for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) {
if (source.endsWith(ending)) {
return `${source.slice(0, -ending.length)},您会怎么接着办?`
}
}
return source
}
function chapterOneEventCopy(sourceEvent) {
if (!sourceEvent) return null
const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')]
if (!fixed) return { ...sourceEvent }
const { choiceOptions, ...copy } = fixed
return {
...sourceEvent,
...copy,
choiceOptions: chapterOneChoiceOptions(
sourceEvent.hotspotId,
sourceEvent.options,
),
}
}
function chapterOneDisplayChapter(chapter) {
if (!chapter || chapter.chapterId !== 'S01-C01') return chapter
const sourceMoment = chapter.emotionMoment
if (!sourceMoment) return { ...chapter }
const sourceChoices = Array.isArray(sourceMoment.playerChoices)
? sourceMoment.playerChoices
: []
return {
...chapter,
emotionMoment: {
...sourceMoment,
prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?',
playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({
...(sourceChoices.find(
(item) => item.choiceId === copy.choiceId,
) || {}),
...copy,
})),
},
}
}
function memoryCardSharePath(memoryCard) {
const cardId = String(memoryCard && memoryCard.cardId || '').trim()
if (!cardId) return ''
return (
`/pages/share/share?cardId=${encodeURIComponent(cardId)}`
+ `&source=${MEMORY_SHARE_SOURCE}`
)
}
function lifeReportPath(memoryCard) {
const cardId = String(memoryCard && memoryCard.cardId || '').trim()
if (!cardId) return ''
return `/pages/report/report?cardId=${encodeURIComponent(cardId)}`
}
function comicAssetIdForPage(page) {
const match = String(page && page.pageId).match(
/^S(\d+)-C(\d+)-P(\d+)$/,
)
if (!match) return ''
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
}
function formatTime(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0))
const minutes = Math.floor(seconds / 60)
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`
}
function pageProgressData(readerState) {
return {
completedIds: [...readerState.completedEventIds],
}
}
function textLength(value) {
return [...String(value || '')].length
}
function pageActor(chapter, page) {
const people = chapter && Array.isArray(chapter.people)
? chapter.people
: []
const visualActorInstanceId = page
&& page.playableVisual
&& page.playableVisual.actorInstanceId
return people.find(
(person) => person.instanceId === (
(page && page.actorInstanceId) || visualActorInstanceId
),
) || null
}
function decisionResultNeedsScroll(
activeEvent,
resultMode,
metrics,
fontScale,
) {
if (!activeEvent || !resultMode || !metrics) return false
const compact = Boolean(metrics.compactHeight)
const horizontalPadding = compact ? 40 : 64
const backdropPadding = compact ? 16 : 24
const modalWidth = Math.max(
1,
Number(metrics.windowWidth || 0)
- Number(metrics.safeLeft || 0)
- Number(metrics.safeRight || 0)
- backdropPadding,
)
const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding)
const bodyFontSize = fontScale === 'xlarge'
? (compact ? 18 : 20)
: (compact ? 17 : 18)
const charactersPerLine = Math.max(
6,
Math.floor(panelWidth / bodyFontSize),
)
const bodyLength = resultMode === 'retry'
? textLength(activeEvent.retryHint)
: (
textLength(activeEvent.reason)
+ textLength(activeEvent.actionAdvice)
)
const estimatedLines = Math.max(
1,
Math.ceil(bodyLength / charactersPerLine),
)
const footerHeight = compact ? 70 : 82
const verticalPadding = compact ? 36 : 54
const availableHeight = Math.max(
90,
Number(metrics.windowHeight || 0)
- Number(metrics.modalTop || 0)
- Number(metrics.safeBottom || 0)
- footerHeight
- verticalPadding,
)
const fixedResultHeight = resultMode === 'correct'
? (compact ? 140 : 118)
: (compact ? 62 : 80)
const estimatedHeight = (
fixedResultHeight
+ estimatedLines * bodyFontSize * 1.48
)
return estimatedHeight > availableHeight
}
Page({
data: {
chapter: null,
chapterNumber: 1,
chapterCount: 15,
completedIds: [],
chapterFinished: false,
activeEvent: null,
activePerson: null,
decisionOpen: false,
textReadingOpen: false,
resultMode: '',
resultDetailOpen: false,
emotionResult: null,
fontScale: 'large',
audioPlaying: false,
audioLoading: false,
audioError: '',
audioCurrent: '0:00',
audioDuration: '0:37',
audioPercent: 0,
audioProgressStyle: 'width:0%',
chapterAudioAvailable: false,
chapterAudioSrc: '',
compactHeight: true,
windowHeight: 390,
pageStyle: '',
topbarStyle: '',
comicPageCount: 0,
currentPage: null,
currentPageIndex: 0,
currentPageNumber: 1,
currentPageId: '',
comicImageSrc: '',
comicImageFallback: '',
comicImageActorFallback: '',
comicImageUsingFallback: false,
comicImageUsesActorFallback: false,
comicImageError: '',
comicImageSource: '',
comicImageShouldResolveRemote: false,
comicPageImageMode: 'scaleToFill',
sharePreviewLocalPath: '',
comicPageImageStyle: '',
comicFocusImageStyle: '',
comicArtStageStyle: '',
comicPreviousDisabled: true,
comicNextDisabled: false,
comicNextLocked: false,
currentEventCompleted: false,
currentInteractionEnabled: false,
presentationMode: 'legacy-60-40-v1',
storyFirstComicMode: false,
chapterOneComicMode: false,
chapterOneBackOpen: false,
chapterOneEmotionOpen: false,
frontControlsRevealed: false,
frontHintVisible: false,
chapterOneReviewEvent: null,
decisionResultNeedsScroll: false,
memoryCollected: false,
memoryFamilyOpen: false,
},
onLoad(options = {}) {
const progress = getProgress()
const chapterNumber = normalizeChapterNumber(
options.chapter || progress.lastChapter || 1,
)
const settings = getSettings()
this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true
this.setData({ fontScale: settings.fontScale || 'large' })
this.applyLayoutMetrics()
this.loadChapter(chapterNumber, options.replay === '1')
this.prepareSharePreview()
},
onResize(event = {}) {
this.applyLayoutMetrics(event.size)
},
onShow() {
this._audioPageHidden = false
if (!this._audioCompanionNavigationLocked) return
this._audioCompanionNavigationLocked = false
this.setData({ audioLoading: false })
},
onHide() {
this._audioPageHidden = true
if (this.audioContext && this.data.audioLoading) {
this.destroyAudio()
return
}
this.pauseAudio(true)
},
onUnload() {
this.destroyAudio()
},
prepareSharePreview() {
const readyPath = String(this.data.sharePreviewLocalPath || '').trim()
if (readyPath) return Promise.resolve(readyPath)
if (this._sharePreviewPromise) return this._sharePreviewPromise
const userDataPath = String(
wx.env && wx.env.USER_DATA_PATH || '',
).trim()
if (!userDataPath || typeof wx.getFileSystemManager !== 'function') {
return Promise.resolve('')
}
const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}`
const fileSystem = wx.getFileSystemManager()
this._sharePreviewPromise = new Promise((resolve) => {
fileSystem.readFile({
filePath: SHARE_PREVIEW_IMAGE,
success: ({ data }) => {
fileSystem.writeFile({
filePath: destinationPath,
data,
success: () => resolve(destinationPath),
fail: () => resolve(''),
})
},
fail: () => resolve(''),
})
}).then((localPath) => {
this._sharePreviewPromise = null
if (localPath) this.setData({ sharePreviewLocalPath: localPath })
return localPath
})
return this._sharePreviewPromise
},
loadChapter(chapterNumber, replayFromStart = false) {
const chapter = chapterOneDisplayChapter(
season.chapters[chapterNumber - 1],
)
const comicModel = buildComicPageModel(chapter, chapterNumber)
const storyFirstComicMode = (
comicModel.presentationMode === STORY_FIRST_PRESENTATION
)
const chapterOneComicMode = Boolean(
storyFirstComicMode && chapter.chapterId === 'S01-C01',
)
const chapterAudio = buildReviewedAudioState(
chapter.audio,
releaseAssets,
)
this._comicModel = comicModel
const storedProgress = getProgress()
const storedReaderProgress = readComicReaderProgress(
storedProgress,
comicModel,
chapterNumber,
)
if (replayFromStart) {
storedReaderProgress.currentPageId = comicModel.firstPageId
if (storyFirstComicMode) {
storedReaderProgress.completedEventIds = []
storedReaderProgress.chapterFinished = false
}
}
const readerState = buildComicReaderState(
comicModel,
storedReaderProgress,
)
const normalizedStorage = mergeComicReaderProgress(
storedProgress,
comicModel,
chapterNumber,
readerState,
)
saveProgress(normalizedStorage)
const collectedMemoryCards = Array.isArray(
normalizedStorage.collectedMemoryCards,
)
? normalizedStorage.collectedMemoryCards
: []
const chapterMemoryPage = comicModel.pageSequence.find(
(page) => page.type === 'memory',
)
const chapterMemoryCardId = chapterMemoryPage
&& chapterMemoryPage.memoryCard
? chapterMemoryPage.memoryCard.cardId
: ''
this.setData({
chapter,
chapterNumber,
chapterCount: season.chapters.length,
...pageProgressData(readerState),
chapterFinished: readerState.chapterFinished,
activeEvent: null,
activePerson: null,
decisionOpen: false,
textReadingOpen: false,
resultMode: '',
resultDetailOpen: false,
decisionResultNeedsScroll: false,
memoryFamilyOpen: false,
emotionResult: null,
audioPlaying: false,
audioLoading: false,
audioError: '',
audioCurrent: '0:00',
audioDuration: formatTime(
chapterAudio.available ? chapter.audio.durationSeconds : 0,
),
audioPercent: 0,
audioProgressStyle: 'width:0%',
chapterAudioAvailable: chapterAudio.available,
chapterAudioSrc: chapterAudio.src,
comicPageCount: comicModel.pageSequence.length,
presentationMode: comicModel.presentationMode || 'legacy-60-40-v1',
storyFirstComicMode,
chapterOneComicMode,
chapterOneBackOpen: false,
chapterOneEmotionOpen: false,
frontControlsRevealed: false,
frontHintVisible: Boolean(
storyFirstComicMode && !this._storyFrontTapHintSeen,
),
memoryCollected: Boolean(
chapterMemoryCardId
&& collectedMemoryCards.includes(chapterMemoryCardId),
),
})
this.applyComicReaderState(readerState, false)
wx.pageScrollTo({ scrollTop: 0, duration: 0 })
},
getComicPageData(pageIndex, readerState = this._readerState) {
const model = this._comicModel
if (!model || model.mode !== 'comic' || !model.pageSequence.length) {
return {}
}
const safeIndex = Math.min(
model.pageSequence.length - 1,
Math.max(0, Number(pageIndex) || 0),
)
const page = model.pageSequence[safeIndex]
const assetId = comicAssetIdForPage(page)
const releaseAsset = assetId ? releaseAssets[assetId] : null
const imageState = buildComicImageState(
page,
releaseAsset,
this.data,
)
const currentPageActor = pageActor(this.data.chapter, page)
// C02-C15 full-page narration is remote-only and fail-closed. Legacy
// cue-level seeds may remain as provenance, but do not expose them as a
// complete page track. A page gets a player route only after both the page
// manifest and immutable release asset have independently been approved.
const chapterNumber = Number(this.data.chapterNumber) || 1
const useRemotePageAudio = chapterNumber >= 2
const pageAudio = useRemotePageAudio
? { available: false, src: '' }
: buildReviewedAudioState(
{
status: page.audioStatus,
assetId: page.audioAssetId,
src: page.audioSrc,
},
releaseAssets,
)
const localAudioCompanion = (
CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null
)
const remoteAudioCompanion = useRemotePageAudio
? getApprovedRemotePageAudio(page.pageId, releaseAssets)
: null
const audioCompanion = localAudioCompanion || remoteAudioCompanion
const audioCompanionPath = localAudioCompanion
? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}`
: (remoteAudioCompanion
? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}`
: '')
const audioCueCount = Array.isArray(page.audioCueIds)
? page.audioCueIds.length
: 0
const completedEventIds = readerState
? readerState.completedEventIds
: this.data.completedIds
const eventCompleted = page.type === 'event'
? completedEventIds.includes(page.eventId)
: false
const activeInteraction = readerState
? readerState.activeInteraction
: null
const currentInteractionEnabled = Boolean(
page.type === 'event'
&& activeInteraction
&& activeInteraction.type === 'event'
&& activeInteraction.pageId === page.pageId
&& activeInteraction.eventId === page.eventId
)
const sourceReviewEvent = page.type === 'event'
? (this.data.chapter.events || []).find(
(item) => item.hotspotId === page.eventId,
) || null
: null
const chapterOneReviewEvent = (
sourceReviewEvent && this.data.chapterOneComicMode
)
? chapterOneEventCopy(sourceReviewEvent)
: sourceReviewEvent
return {
currentPage: {
...page,
illustrationAssetId: assetId,
audioAvailable: pageAudio.available,
audioSrc: pageAudio.src,
audioCompanionAvailable: Boolean(audioCompanion),
audioCompanionPath,
audioCueCount,
audioPlanned: audioCueCount > 0,
audioDurationLabel: formatTime(
audioCompanion
? audioCompanion.durationSeconds
: page.audioDurationSeconds,
),
eventCompleted,
displayCaption: (
eventCompleted && page.resolvedCaption
? page.resolvedCaption
: page.caption
),
},
currentPageIndex: safeIndex,
currentPageNumber: safeIndex + 1,
currentPageId: page.pageId,
comicImageSrc: imageState.src,
comicImageFallback: imageState.fallback,
comicImageActorFallback: imageState.actorFallback,
comicImageUsingFallback: imageState.usingFallback,
comicImageUsesActorFallback: imageState.usingActorFallback,
comicImageError: imageState.error,
comicImageSource: imageState.source,
comicImageShouldResolveRemote: imageState.shouldResolveRemote,
comicPageImageMode: getComicPageImageMode(
currentPageActor,
imageState.usingActorFallback,
),
comicPageImageStyle: getComicPageImageStyle(
currentPageActor,
imageState.usingActorFallback,
),
comicArtStageStyle: getComicArtStageStyle(
this._layoutMetrics,
page,
),
comicPreviousDisabled: readerState
? !readerState.canGoPrevious
: safeIndex <= 0,
comicNextDisabled: safeIndex >= model.pageSequence.length - 1,
comicNextLocked: readerState
? readerState.nextPageLocked
: false,
currentEventCompleted: eventCompleted,
currentInteractionEnabled,
chapterOneReviewEvent,
}
},
applyComicReaderState(readerState, persist = true) {
if (!readerState || !readerState.valid) return
const pageData = this.getComicPageData(
readerState.currentPageIndex,
readerState,
)
if (!pageData.currentPage) return
const pageChanged = Boolean(
this.data.currentPageId
&& this.data.currentPageId !== pageData.currentPageId
)
const nextAudioKey = `page:${pageData.currentPage.pageId}`
if (
this._audioProgressKey
&& this._audioProgressKey.startsWith('page:')
&& this._audioProgressKey !== nextAudioKey
) {
this.destroyAudio()
}
this._readerState = readerState
this._readerProgress = {
currentPageId: readerState.currentPageId,
completedEventIds: [...readerState.completedEventIds],
chapterFinished: readerState.chapterFinished,
}
this.setData({
...pageData,
...pageProgressData(readerState),
chapterFinished: readerState.chapterFinished,
textReadingOpen: false,
...(pageChanged ? {
chapterOneBackOpen: false,
chapterOneEmotionOpen: false,
frontControlsRevealed: false,
frontHintVisible: false,
chapterOneReviewEvent: pageData.chapterOneReviewEvent || null,
decisionOpen: false,
activeEvent: null,
activePerson: null,
resultMode: '',
resultDetailOpen: false,
emotionResult: null,
decisionResultNeedsScroll: false,
comicFocusImageStyle: '',
} : {}),
})
if (pageData.comicImageShouldResolveRemote) {
this.resolveComicPageAsset(
pageData.currentPage,
readerState.currentPageIndex,
)
}
if (persist) {
saveProgress(mergeComicReaderProgress(
getProgress(),
this._comicModel,
this.data.chapterNumber,
this._readerProgress,
))
}
this.ensureCurrentMemoryCollected(pageData.currentPage)
},
applyReaderAction(action, persist = true) {
if (!this._comicModel || !this._readerProgress) {
return this._readerState
}
const nextProgress = applyComicReaderAction(
this._comicModel,
this._readerProgress,
action,
)
const nextState = buildComicReaderState(
this._comicModel,
nextProgress,
)
this.applyComicReaderState(nextState, persist)
return nextState
},
refreshComicPage() {
if (!this._readerState) return
this.setData(this.getComicPageData(
this._readerState.currentPageIndex,
this._readerState,
))
},
getAssetManager() {
if (!this._assetManager) {
this._assetManager = createAssetManager({
assetPlatform: createAssetPlatformFacade(),
manifest: releaseAssets,
...assetReleaseConfig,
})
}
return this._assetManager
},
async resolveComicPageAsset(page, pageIndex) {
const assetId = comicAssetIdForPage(page)
const releaseAsset = assetId ? releaseAssets[assetId] : null
if (
!releaseAsset
|| releaseAsset.kind !== 'image'
|| releaseAsset.localSeed
|| !releaseAsset.remotePath
) return
const pageId = page.pageId
try {
const resolved = await this.getAssetManager().resolve(assetId)
if (
this.data.currentPageId === pageId
&& resolved.available
&& resolved.uri
) {
this.setData({
comicImageSrc: resolved.uri,
comicImageUsingFallback: false,
comicImageUsesActorFallback: false,
comicImageError: '',
comicImageSource: resolved.source,
comicPageImageMode: 'scaleToFill',
comicPageImageStyle: '',
comicFocusImageStyle: '',
})
} else if (
this.data.currentPageId === pageId
&& !this.data.comicImageSrc
) {
this.setData({
comicImageError: IMAGE_UNAVAILABLE_MESSAGE,
comicImageSource: 'text-fallback',
})
}
} catch (error) {
if (
this.data.currentPageId === pageId
&& !this.data.comicImageSrc
) {
this.setData({
comicImageError: IMAGE_UNAVAILABLE_MESSAGE,
comicImageSource: 'text-fallback',
})
}
}
const model = this._comicModel
if (!model || this.data.currentPageId !== pageId) return
const ahead = Math.max(
0,
Number(assetReleaseConfig.prefetchAhead) || 0,
)
const nextAssetIds = model.pageSequence
.slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead)
.map(comicAssetIdForPage)
.filter((nextAssetId) => (
releaseAssets[nextAssetId]
&& !releaseAssets[nextAssetId].localSeed
&& releaseAssets[nextAssetId].remotePath
))
if (nextAssetIds.length) {
this.getAssetManager().prefetch(nextAssetIds).catch(() => {})
}
},
onComicImageLoad() {
if (this.data.comicImageError) {
this.setData({ comicImageError: '' })
}
},
onComicImageError() {
const actorFallback = this.data.comicImageActorFallback
const fallback = this.data.comicImageFallback
const currentActor = pageActor(
this.data.chapter,
this.data.currentPage,
)
if (
actorFallback
&& this.data.comicImageSrc !== actorFallback
&& !this.data.comicImageUsesActorFallback
) {
this.setData({
comicImageSrc: actorFallback,
comicImageUsingFallback: true,
comicImageUsesActorFallback: true,
comicImageError: '',
comicImageSource: 'actor-fallback',
comicPageImageMode: getComicPageImageMode(
currentActor,
true,
),
comicPageImageStyle: getComicPageImageStyle(
currentActor,
true,
),
comicFocusImageStyle: getComicFocusImageStyle(
this.data.activePerson
|| pageActor(this.data.chapter, this.data.currentPage),
true,
),
})
return
}
if (
fallback
&& this.data.comicImageSrc !== fallback
&& (
!this.data.comicImageUsingFallback
|| this.data.comicImageUsesActorFallback
)
) {
this.setData({
comicImageSrc: fallback,
comicImageUsingFallback: true,
comicImageUsesActorFallback: false,
comicImageError: '',
comicImageSource: 'local-fallback',
comicPageImageMode: 'scaleToFill',
comicPageImageStyle: '',
comicFocusImageStyle: '',
})
return
}
this.setData({
comicImageSrc: '',
comicImageUsesActorFallback: false,
comicImageError: IMAGE_UNAVAILABLE_MESSAGE,
comicImageSource: 'text-fallback',
comicPageImageMode: 'scaleToFill',
comicPageImageStyle: '',
comicFocusImageStyle: '',
})
},
applyLayoutMetrics(resizeInfo = null) {
let windowInfo = {}
try {
windowInfo = wx.getWindowInfo
? wx.getWindowInfo()
: wx.getSystemInfoSync()
} catch (error) {
windowInfo = {}
}
if (resizeInfo) {
windowInfo = {
...windowInfo,
windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth,
windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight,
}
}
let menuRect = {}
try {
menuRect = wx.getMenuButtonBoundingClientRect
? wx.getMenuButtonBoundingClientRect()
: {}
} catch (error) {
menuRect = {}
}
const metrics = getChapterLayoutMetrics(windowInfo, menuRect)
this._layoutMetrics = metrics
const pageStyle = [
`height:${metrics.windowHeight}px`,
`--safe-left:${metrics.safeLeft}px`,
`--safe-right:${metrics.safeRight}px`,
`--safe-bottom:${metrics.safeBottom}px`,
`--modal-top:${metrics.modalTop}px`,
`--topbar-height:${metrics.topbarHeight}px`,
].join(';')
const topbarStyle = [
`height:${metrics.topbarHeight}px`,
`padding-top:${metrics.topPadding}px`,
`padding-right:${metrics.capsuleReserve}px`,
`padding-bottom:${metrics.bottomPadding}px`,
`padding-left:${metrics.leftInset}px`,
].join(';')
const nextData = {
compactHeight: metrics.compactHeight,
windowHeight: metrics.windowHeight,
pageStyle,
topbarStyle,
comicArtStageStyle: getComicArtStageStyle(
metrics,
this.data.currentPage,
),
decisionResultNeedsScroll: decisionResultNeedsScroll(
this.data.activeEvent,
this.data.resultMode,
metrics,
this.data.fontScale,
),
}
this.setData(nextData)
},
openCurrentFrontAction() {
const page = this.data.currentPage
if (
!this.data.storyFirstComicMode
|| !page
|| this.data.chapterOneBackOpen
|| this.data.decisionOpen
|| this.data.chapterOneEmotionOpen
|| !this.data.frontControlsRevealed
) return
if (page.type === 'event' && this.data.currentInteractionEnabled) {
this.openComicActor()
return
}
if (page.type === 'emotion') {
this.openChapterOneEmotion()
return
}
this.openChapterOneBack()
},
revealCurrentFrontControls() {
if (
!this.data.storyFirstComicMode
|| !this.data.currentPage
|| this.data.chapterOneBackOpen
|| this.data.decisionOpen
|| this.data.chapterOneEmotionOpen
|| this.data.frontControlsRevealed
) return
this.setData({
frontControlsRevealed: true,
frontHintVisible: false,
})
const settings = getSettings()
if (settings.storyFrontTapHintSeen === true) return
settings.storyFrontTapHintSeen = true
saveSettings(settings)
this._storyFrontTapHintSeen = true
},
openComicActor() {
const page = this.data.currentPage
const interaction = this._readerState
? this._readerState.activeInteraction
: null
if (
!page
|| page.type !== 'event'
|| !interaction
|| interaction.type !== 'event'
|| interaction.pageId !== page.pageId
|| interaction.eventId !== page.eventId
) return
if (this.data.storyFirstComicMode) {
if (!this.data.frontControlsRevealed) return
this.openChapterOneBack()
return
}
const person = this.data.chapter.people.find(
(item) => item.instanceId === page.actorInstanceId,
)
if (!person) return
this.openDecision(page.eventId, person, { question: page.question })
},
openChapterOneBack() {
const page = this.data.currentPage
if (
!this.data.storyFirstComicMode
|| !page
|| page.type === 'emotion'
|| !this.data.frontControlsRevealed
) return
this.setData({ chapterOneBackOpen: true })
},
startChapterOneDecisionFromBack() {
const page = this.data.currentPage
if (
!this.data.storyFirstComicMode
|| !this.data.chapterOneBackOpen
|| !page
|| page.type !== 'event'
|| this.data.currentEventCompleted
) return
const person = this.data.chapter.people.find(
(item) => item.instanceId === page.actorInstanceId,
)
if (!person) return
this.setData({ chapterOneBackOpen: false })
this.openDecision(page.eventId, person, { question: page.question })
},
closeChapterOneBack() {
this.setData({
chapterOneBackOpen: false,
frontControlsRevealed: false,
frontHintVisible: false,
})
},
nextChapterOnePageFromBack() {
this.setData({ chapterOneBackOpen: false })
this.nextComicPage()
},
openChapterOneEmotion() {
const page = this.data.currentPage
const readerState = this._readerState
const interaction = readerState ? readerState.activeInteraction : null
if (
!this.data.storyFirstComicMode
|| !page
|| page.type !== 'emotion'
|| !this.data.frontControlsRevealed
) return
if (
interaction
&& interaction.type === 'emotion'
&& interaction.pageId === page.pageId
) {
this.setData({ chapterOneEmotionOpen: true })
return
}
// 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。
// 不能继续绑定一个已经没有 activeInteraction 的空操作。
if (readerState && readerState.chapterFinished && readerState.canGoNext) {
this.applyReaderAction({ type: 'next-page' })
}
},
closeChapterOneEmotion() {
this.setData({
chapterOneEmotionOpen: false,
emotionResult: null,
frontControlsRevealed: false,
frontHintVisible: false,
})
},
openTextReading() {
if (!this.data.currentPage) return
this.setData({ textReadingOpen: true })
},
closeTextReading() {
this.setData({ textReadingOpen: false })
},
openDecision(eventId, person, displayOverrides = {}) {
const interaction = this._readerState
? this._readerState.activeInteraction
: null
if (
!interaction
|| interaction.type !== 'event'
|| interaction.eventId !== eventId
|| interaction.pageId !== this.data.currentPageId
) return
const sourceEvent = this.data.chapter.events.find(
(item) => item.hotspotId === eventId,
)
if (!sourceEvent) return
const activeEvent = {
...(this.data.chapterOneComicMode
? chapterOneEventCopy(sourceEvent)
: sourceEvent),
...displayOverrides,
...(this.data.storyFirstComicMode
? {
question: storyFirstQuestion(
sourceEvent,
displayOverrides.question,
),
}
: {}),
choiceOptions: this.data.storyFirstComicMode
? storyFirstChoiceOptions(
sourceEvent.hotspotId,
sourceEvent.options,
)
: [],
}
this.setData({
activeEvent,
activePerson: person,
decisionOpen: true,
resultMode: '',
resultDetailOpen: false,
decisionResultNeedsScroll: false,
comicFocusImageStyle: getComicFocusImageStyle(
person,
this.data.comicImageUsesActorFallback,
),
})
},
answer(event) {
const value = event.currentTarget.dataset.value
const activeEvent = this.data.activeEvent
const interaction = this._readerState
? this._readerState.activeInteraction
: null
if (
!activeEvent
|| this.data.resultMode
|| !interaction
|| interaction.type !== 'event'
|| interaction.eventId !== activeEvent.hotspotId
|| interaction.pageId !== this.data.currentPageId
) return
if (value !== activeEvent.correctAnswer) {
this.setData({
resultMode: 'retry',
decisionResultNeedsScroll: decisionResultNeedsScroll(
activeEvent,
'retry',
this._layoutMetrics,
this.data.fontScale,
),
})
wx.vibrateShort({ type: 'light' })
return
}
const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36'
if (!needsSafetyDetail && !this.data.storyFirstComicMode) {
const nextState = this.applyReaderAction({
type: 'complete-event',
eventId: activeEvent.hotspotId,
})
if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return
}
this.setData({
resultMode: 'correct',
resultDetailOpen: false,
decisionResultNeedsScroll: needsSafetyDetail
? false
: decisionResultNeedsScroll(
activeEvent,
'correct',
this._layoutMetrics,
this.data.fontScale,
),
})
wx.vibrateShort({ type: 'light' })
},
retryAnswer() {
this.setData({
resultMode: '',
resultDetailOpen: false,
decisionResultNeedsScroll: false,
})
},
openResultDetail() {
const activeEvent = this.data.activeEvent
if (
this.data.resultMode !== 'correct'
|| !activeEvent
|| activeEvent.hotspotId !== 'S01-H36'
) return
this.setData({
resultDetailOpen: true,
decisionResultNeedsScroll: decisionResultNeedsScroll(
activeEvent,
'correct',
this._layoutMetrics,
this.data.fontScale,
),
})
},
continueAfterDecision() {
if (
this.data.storyFirstComicMode
&& this.data.resultMode === 'correct'
) {
if (
this.data.activeEvent
&& this.data.activeEvent.hotspotId === 'S01-H36'
&& !this.data.resultDetailOpen
) {
this.openResultDetail()
return
}
this.finishChapterOneDecision(false)
return
}
const activeEvent = this.data.activeEvent
if (
this.data.resultMode === 'correct'
&& activeEvent
&& activeEvent.hotspotId === 'S01-H36'
) {
if (!this.data.resultDetailOpen) {
this.openResultDetail()
return
}
const nextState = this.applyReaderAction({
type: 'complete-event',
eventId: activeEvent.hotspotId,
})
if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return
}
this.setData({
decisionOpen: false,
activeEvent: null,
activePerson: null,
resultMode: '',
resultDetailOpen: false,
decisionResultNeedsScroll: false,
comicFocusImageStyle: '',
})
this.refreshComicPage()
},
closeDecision() {
if (
this.data.storyFirstComicMode
&& this.data.resultMode === 'correct'
) {
if (
this.data.activeEvent
&& this.data.activeEvent.hotspotId === 'S01-H36'
&& !this.data.resultDetailOpen
) {
this.openResultDetail()
return
}
this.finishChapterOneDecision(false)
return
}
if (this.data.resultMode === 'correct') {
if (
this.data.activeEvent
&& this.data.activeEvent.hotspotId === 'S01-H36'
&& !this.data.resultDetailOpen
) {
this.openResultDetail()
return
}
this.continueAfterDecision()
return
}
this.setData({
decisionOpen: false,
activeEvent: null,
activePerson: null,
resultMode: '',
resultDetailOpen: false,
decisionResultNeedsScroll: false,
comicFocusImageStyle: '',
chapterOneBackOpen: false,
frontControlsRevealed: false,
frontHintVisible: false,
})
},
finishChapterOneDecision(goNext) {
const activeEvent = this.data.activeEvent
if (
!this.data.storyFirstComicMode
|| this.data.resultMode !== 'correct'
|| !activeEvent
|| (
activeEvent.hotspotId === 'S01-H36'
&& !this.data.resultDetailOpen
)
) return
const nextState = this.applyReaderAction({
type: 'complete-event',
eventId: activeEvent.hotspotId,
})
if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return
this.setData({
decisionOpen: false,
activeEvent: null,
activePerson: null,
resultMode: '',
resultDetailOpen: false,
decisionResultNeedsScroll: false,
comicFocusImageStyle: '',
chapterOneBackOpen: false,
frontControlsRevealed: false,
frontHintVisible: false,
})
if (goNext) this.applyReaderAction({ type: 'next-page' })
else this.refreshComicPage()
},
returnFromChapterOneDecision() {
this.finishChapterOneDecision(false)
},
nextFromChapterOneDecision() {
this.finishChapterOneDecision(true)
},
chooseEmotion(event) {
const choiceId = event.currentTarget.dataset.choice
const moment = this.data.chapter.emotionMoment
const interaction = this._readerState
? this._readerState.activeInteraction
: null
if (
!interaction
|| interaction.type !== 'emotion'
|| interaction.emotionMomentId !== moment.emotionMomentId
|| interaction.pageId !== this.data.currentPageId
) return
const choice = moment.playerChoices.find((item) => item.choiceId === choiceId)
if (!choice) return
this.setData({
emotionResult: {
feedback: choice.characterFeedback,
echo: moment.tableEcho,
category: moment.echoCategory,
},
})
},
finishChapterOneEmotionAndTurn() {
const moment = this.data.chapter && this.data.chapter.emotionMoment
if (
!this.data.storyFirstComicMode
|| !moment
|| !this.data.emotionResult
) return
const nextState = this.applyReaderAction({
type: 'complete-emotion',
emotionMomentId: moment.emotionMomentId,
})
if (!nextState || !nextState.chapterFinished) return
if (nextState.canGoNext) {
this.applyReaderAction({ type: 'next-page' })
}
},
finishEmotion() {
const moment = this.data.chapter.emotionMoment
const nextState = this.applyReaderAction({
type: 'complete-emotion',
emotionMomentId: moment.emotionMomentId,
})
if (!nextState || !nextState.chapterFinished) return
// Completion unlocks P08 but deliberately leaves the reader on P07. The
// memory card opens only after the user's next explicit tap or page turn.
if (typeof wx.vibrateShort === 'function') {
wx.vibrateShort({ type: 'light' })
}
},
finishComicEmotion() {
if (!this.data.emotionResult && !this.data.chapterFinished) {
wx.showToast({ title: '先选一种陪伴方式', icon: 'none' })
return
}
if (this.data.chapterFinished) {
this.applyReaderAction({ type: 'next-page' })
return
}
this.finishEmotion()
},
previousComicPage() {
this.applyReaderAction({ type: 'previous-page' })
},
nextComicPage() {
const page = this.data.currentPage
if (!page) return
if (this._readerState && this._readerState.nextPageLocked) {
if (page.type === 'event') {
wx.showToast({
title: page.playableVisual.kind === 'evidence-composite'
? '先看看桌上的东西'
: '先看看画里发生了什么',
icon: 'none',
})
return
}
if (page.type === 'emotion') {
wx.showToast({ title: '先收下这一桌的回声', icon: 'none' })
return
}
}
if (
!this._readerState
|| !this._readerState.canGoNext
) {
return
}
this.applyReaderAction({ type: 'next-page' })
},
onComicTouchStart(event) {
const touch = event && event.touches && event.touches[0]
if (!touch) return
this._comicTouchStart = {
x: Number(touch.clientX) || 0,
y: Number(touch.clientY) || 0,
at: Date.now(),
}
},
onComicTouchEnd(event) {
const start = this._comicTouchStart
this._comicTouchStart = null
const touch = event && event.changedTouches && event.changedTouches[0]
if (!start || !touch) return
const deltaX = (Number(touch.clientX) || 0) - start.x
const deltaY = (Number(touch.clientY) || 0) - start.y
const elapsed = Date.now() - start.at
if (
elapsed > 900
|| Math.abs(deltaX) < 64
|| Math.abs(deltaX) < Math.abs(deltaY) * 1.35
) return
if (deltaX > 0) this.previousComicPage()
else this.nextComicPage()
},
collectMemoryCard() {
const page = this.data.currentPage
const card = page && page.memoryCard
if (!card || !card.cardId) return
const chapter = this.data.chapter || {}
const progress = addMemoryCard(getProgress(), card, {
chapterId: chapter.chapterId,
chapterNumber: this.data.chapterNumber || chapter.chapterNumber,
chapterTitle: chapter.title,
})
saveProgress(progress)
this.setData({ memoryCollected: true })
if (typeof wx.showToast === 'function') {
wx.showToast({
title: '已收进封面的桂香岁月',
icon: 'none',
})
}
if (typeof wx.vibrateShort === 'function') {
wx.vibrateShort({ type: 'light' })
}
},
ensureCurrentMemoryCollected(page) {
const card = page && page.type === 'memory' && page.memoryCard
if (!card || !card.cardId) return false
const existing = getProgress()
const collected = Array.isArray(existing.collectedMemoryCards)
? existing.collectedMemoryCards
: []
const snapshots = (
existing.memoryCardSnapshots
&& typeof existing.memoryCardSnapshots === 'object'
&& !Array.isArray(existing.memoryCardSnapshots)
)
? existing.memoryCardSnapshots
: {}
const alreadyComplete = (
collected.includes(card.cardId)
&& snapshots[card.cardId]
&& snapshots[card.cardId].cardId === card.cardId
)
if (alreadyComplete) {
if (!this.data.memoryCollected) {
this.setData({ memoryCollected: true })
}
return false
}
const chapter = this.data.chapter || {}
const progress = addMemoryCard(existing, card, {
chapterId: chapter.chapterId,
chapterNumber: this.data.chapterNumber || chapter.chapterNumber,
chapterTitle: chapter.title,
})
saveProgress(progress)
this.setData({ memoryCollected: true })
return true
},
openLifeReport() {
const page = this.data.currentPage
const card = page && page.type === 'memory' && page.memoryCard
const url = lifeReportPath(card)
if (!url) return
wx.navigateTo({ url })
},
toggleMemoryFamily() {
this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen })
},
toggleComicPageAudio() {
const page = this.data.currentPage
if (page && page.audioCompanionAvailable && page.audioCompanionPath) {
if (this._audioCompanionNavigationLocked) return
this._audioCompanionNavigationLocked = true
this.setData({ audioLoading: true, audioError: '' })
wx.navigateTo({
url: page.audioCompanionPath,
fail: () => {
this._audioCompanionNavigationLocked = false
this.setData({
audioLoading: false,
audioError: '有声夹页暂时没有打开,请稍后再试。',
})
if (typeof wx.showToast === 'function') {
wx.showToast({
title: '有声夹页没打开,请稍后再试',
icon: 'none',
})
}
},
})
return
}
if (!page || !page.audioAvailable || !page.audioSrc) return
this.toggleAudioSource(
page.audioSrc,
`page:${page.pageId}`,
Number(page.audioDurationSeconds) || 0,
{
restartFromBeginning: true,
resumeSavedProgress: false,
persistProgress: false,
},
)
},
toggleFont() {
const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large'
const settings = getSettings()
settings.fontScale = fontScale
saveSettings(settings)
this.setData({
fontScale,
decisionResultNeedsScroll: decisionResultNeedsScroll(
this.data.activeEvent,
this.data.resultMode,
this._layoutMetrics,
fontScale,
),
})
},
toggleAudio() {
const chapter = this.data.chapter
if (
!chapter
|| !this.data.chapterAudioAvailable
|| !this.data.chapterAudioSrc
) return
this.toggleAudioSource(
this.data.chapterAudioSrc,
chapter.chapterId,
Number(chapter.audio.durationSeconds) || 0,
)
},
toggleAudioSource(src, progressKey, durationSeconds, options = {}) {
if (!src || !progressKey) return
if (
this.audioContext
&& (
this._audioProgressKey !== progressKey
|| this._audioSrc !== src
)
) {
this.destroyAudio()
}
if (!this.audioContext) {
this.createAudioContext({
src,
progressKey,
durationSeconds,
resumeSavedProgress: options.resumeSavedProgress,
persistProgress: options.persistProgress,
})
this.setData({
audioCurrent: '0:00',
audioDuration: formatTime(durationSeconds),
audioPercent: 0,
audioProgressStyle: 'width:0%',
})
}
if (this.data.audioPlaying) {
this.audioContext.pause()
return
}
if (
options.restartFromBeginning
&& this.audioContext
&& Number(this.data.audioPercent) > 0
) {
this.audioContext.seek(0)
this.setData({
audioCurrent: '0:00',
audioPercent: 0,
audioProgressStyle: 'width:0%',
})
}
this.setData({ audioLoading: true, audioError: '' })
this.audioContext.play()
},
createAudioContext(options = {}) {
const src = String(options.src || '')
const progressKey = String(options.progressKey || '')
const durationSeconds = Math.max(
0,
Number(options.durationSeconds) || 0,
)
if (!src || !progressKey) return
const context = wx.createInnerAudioContext()
context.autoplay = false
context.src = src
this._audioProgressKey = progressKey
this._audioSrc = src
this._audioDurationSeconds = durationSeconds
this._audioResumeSavedProgress = options.resumeSavedProgress !== false
this._audioPersistProgress = options.persistProgress !== false
const isCurrentContext = () => this.audioContext === context
context.onCanplay(() => {
if (!isCurrentContext()) return
const saved = getAudioProgress()
const resumeAt = Number(saved[progressKey] || 0)
if (
this._audioResumeSavedProgress
&&
resumeAt > 1
&& durationSeconds > 2
&& resumeAt < durationSeconds - 2
) {
context.seek(resumeAt)
}
this.setData({ audioLoading: false, audioError: '' })
})
context.onPlay(() => {
if (!isCurrentContext()) {
context.pause()
return
}
if (this._audioPageHidden) {
context.pause()
this.setData({
audioPlaying: false,
audioLoading: false,
})
return
}
this.setData({ audioPlaying: true, audioLoading: false, audioError: '' })
})
context.onPause(() => {
if (!isCurrentContext()) return
this.setData({ audioPlaying: false, audioLoading: false })
this.saveCurrentAudioTime()
})
context.onStop(() => {
if (!isCurrentContext()) return
this.setData({ audioPlaying: false, audioLoading: false })
this.saveCurrentAudioTime()
})
context.onEnded(() => {
if (!isCurrentContext()) return
const saved = getAudioProgress()
if (this._audioPersistProgress) saved[progressKey] = 0
else delete saved[progressKey]
saveAudioProgress(saved)
const completedDuration = context.duration || durationSeconds
this.setData({
audioPlaying: false,
audioLoading: false,
audioCurrent: formatTime(completedDuration),
audioDuration: formatTime(completedDuration),
audioPercent: 100,
audioProgressStyle: 'width:100%',
})
})
context.onTimeUpdate(() => {
if (!isCurrentContext()) return
const duration = context.duration || durationSeconds
const current = context.currentTime || 0
this.setData({
audioCurrent: formatTime(current),
audioDuration: formatTime(duration),
audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0,
audioProgressStyle: `width:${
duration > 0 ? Math.min(100, current / duration * 100) : 0
}%`,
})
})
context.onError(() => {
if (!isCurrentContext()) return
this.setData({
audioPlaying: false,
audioLoading: false,
audioError: '音频暂时没有打开,文字游戏仍可继续。',
})
})
this.audioContext = context
},
saveCurrentAudioTime() {
if (
!this.audioContext
|| !this._audioProgressKey
|| !this._audioPersistProgress
) return
const saved = getAudioProgress()
saved[this._audioProgressKey] = this.audioContext.currentTime || 0
saveAudioProgress(saved)
},
pauseAudio(force = false) {
if (!this.audioContext) return
if (force || this.data.audioPlaying || this.data.audioLoading) {
this.audioContext.pause()
this.setData({
audioPlaying: false,
audioLoading: false,
})
}
},
destroyAudio() {
if (!this.audioContext) return
this.saveCurrentAudioTime()
this.audioContext.destroy()
this.audioContext = null
this._audioProgressKey = ''
this._audioSrc = ''
this._audioDurationSeconds = 0
this._audioResumeSavedProgress = true
this._audioPersistProgress = true
this.setData({
audioPlaying: false,
audioLoading: false,
audioError: '',
audioCurrent: '0:00',
audioDuration: '0:00',
audioPercent: 0,
audioProgressStyle: 'width:0%',
})
},
previousChapter() {
if (this.data.chapterNumber <= 1) return
this.destroyAudio()
wx.redirectTo({
url: chapterRoute(this.data.chapterNumber - 1),
})
},
nextChapter() {
if (this.data.chapterNumber >= this.data.chapterCount) return
this.destroyAudio()
wx.redirectTo({
url: chapterRoute(this.data.chapterNumber + 1),
})
},
goCatalog() {
this.destroyAudio()
const pages = getCurrentPages()
const previous = pages[pages.length - 2]
if (previous && previous.route === 'pages/catalog/catalog') {
wx.navigateBack()
return
}
wx.redirectTo({ url: '/pages/catalog/catalog' })
},
onShareAppMessage(options = {}) {
const memoryCard = (
options.from === 'button'
&& this.data.currentPage
&& this.data.currentPage.type === 'memory'
)
? this.data.currentPage.memoryCard
: null
const sharePayload = {
title: memoryCard && memoryCard.familyLine
? `带回家的一句话:${memoryCard.familyLine}`
: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`,
path: memoryCardSharePath(memoryCard)
|| chapterRoute(this.data.chapterNumber),
}
const localPath = String(this.data.sharePreviewLocalPath || '').trim()
if (localPath) {
return {
...sharePayload,
imageUrl: localPath,
}
}
return {
...sharePayload,
promise: this.prepareSharePreview().then((resolvedPath) => (
resolvedPath
? { ...sharePayload, imageUrl: resolvedPath }
: sharePayload
)),
}
},
onShareTimeline() {
return {
title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`,
query: `chapter=${this.data.chapterNumber}`,
}
},
})