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