gengxin
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/home/home",
|
||||
"pages/catalog/catalog",
|
||||
"pages/cast/cast",
|
||||
"pages/memories/memories",
|
||||
"pages/share/share",
|
||||
"pages/report/report"
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "package-game",
|
||||
"name": "game",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-audio-c01-a",
|
||||
"name": "audio-c01-a",
|
||||
"pages": [
|
||||
"pages/player/player"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-audio-c01-b",
|
||||
"name": "audio-c01-b",
|
||||
"pages": [
|
||||
"pages/player/player"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-audio-player",
|
||||
"name": "audio-player",
|
||||
"pages": [
|
||||
"pages/player/player"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-04",
|
||||
"name": "chapter-04",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-02",
|
||||
"name": "chapter-02",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-03",
|
||||
"name": "chapter-03",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-05",
|
||||
"name": "chapter-05",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-06",
|
||||
"name": "chapter-06",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-07",
|
||||
"name": "chapter-07",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-08",
|
||||
"name": "chapter-08",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-09",
|
||||
"name": "chapter-09",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-10",
|
||||
"name": "chapter-10",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-11",
|
||||
"name": "chapter-11",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-12",
|
||||
"name": "chapter-12",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-13",
|
||||
"name": "chapter-13",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-14",
|
||||
"name": "chapter-14",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "package-chapter-15",
|
||||
"name": "chapter-15",
|
||||
"pages": [
|
||||
"pages/chapter/chapter"
|
||||
]
|
||||
}
|
||||
],
|
||||
"preloadRule": {
|
||||
"package-game/pages/chapter/chapter": {
|
||||
"network": "all",
|
||||
"packages": [
|
||||
"audio-c01-a"
|
||||
]
|
||||
},
|
||||
"package-audio-c01-a/pages/player/player": {
|
||||
"network": "all",
|
||||
"packages": [
|
||||
"audio-c01-b"
|
||||
]
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"navigationStyle": "custom",
|
||||
"pageOrientation": "landscape",
|
||||
"backgroundColor": "#201711",
|
||||
"backgroundTextStyle": "light"
|
||||
},
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
page {
|
||||
--ink: #30251c;
|
||||
--muted: #745f48;
|
||||
--paper: #f3e5bd;
|
||||
--paper-deep: #dfc995;
|
||||
--cinnabar: #9f3028;
|
||||
--cinnabar-dark: #6f201b;
|
||||
--jade: #275f4e;
|
||||
--jade-soft: #d7e4d5;
|
||||
--danger-soft: #ead2c7;
|
||||
--night: #211812;
|
||||
min-height: 100%;
|
||||
color: var(--ink);
|
||||
background: var(--night);
|
||||
font-family: "Songti SC", "STSong", "Noto Serif CJK SC", serif;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
button,
|
||||
image,
|
||||
scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.safe-shell {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding:
|
||||
calc(24rpx + constant(safe-area-inset-top))
|
||||
calc(34rpx + constant(safe-area-inset-right))
|
||||
calc(24rpx + constant(safe-area-inset-bottom))
|
||||
calc(34rpx + constant(safe-area-inset-left));
|
||||
padding:
|
||||
calc(24rpx + env(safe-area-inset-top))
|
||||
calc(34rpx + env(safe-area-inset-right))
|
||||
calc(24rpx + env(safe-area-inset-bottom))
|
||||
calc(34rpx + env(safe-area-inset-left));
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 30%),
|
||||
#211812;
|
||||
}
|
||||
|
||||
.paper-panel {
|
||||
color: var(--ink);
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px),
|
||||
var(--paper);
|
||||
border: 2rpx solid #9e845e;
|
||||
box-shadow: 0 18rpx 42rpx rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.seal {
|
||||
display: flex;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff0cd;
|
||||
background: var(--cinnabar);
|
||||
border: 3rpx solid #d99b72;
|
||||
border-radius: 10rpx;
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--cinnabar);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.quiet-button {
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 30rpx;
|
||||
border-radius: 12rpx;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
font-size: 28rpx;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
color: #fff1ce;
|
||||
background: var(--cinnabar);
|
||||
box-shadow: 0 10rpx 22rpx rgba(111, 32, 27, 0.25);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
color: #fff0cf;
|
||||
background: #3b2a20;
|
||||
border: 2rpx solid #856646;
|
||||
}
|
||||
|
||||
.quiet-button {
|
||||
min-height: 60rpx;
|
||||
padding: 0 22rpx;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 248, 229, 0.62);
|
||||
border: 2rpx solid #a78f68;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.status-pill.internal {
|
||||
color: #7b231d;
|
||||
background: #ead2b4;
|
||||
border: 2rpx solid #b8744e;
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.safe-shell {
|
||||
padding:
|
||||
calc(10px + constant(safe-area-inset-top))
|
||||
calc(14px + constant(safe-area-inset-right))
|
||||
calc(10px + constant(safe-area-inset-bottom))
|
||||
calc(14px + constant(safe-area-inset-left));
|
||||
padding:
|
||||
calc(10px + env(safe-area-inset-top))
|
||||
calc(14px + env(safe-area-inset-right))
|
||||
calc(10px + env(safe-area-inset-bottom))
|
||||
calc(14px + env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.quiet-button {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.quiet-button {
|
||||
min-height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
module.exports = [
|
||||
{
|
||||
id: 'tang-shouan',
|
||||
name: '唐守安',
|
||||
title: '唐侦探/唐大夫',
|
||||
eras: '1978—2026',
|
||||
asset: '/assets/characters/tang-shouan.jpg',
|
||||
role: '从厂卫生员到受邻里敬重的中医从业者;始终先观察、后开口,最后才真正入席。',
|
||||
visual: '素色深靛蓝日常外套,旧棕布卫生包只暗示家学,不画成古装神医。',
|
||||
},
|
||||
{
|
||||
id: 'qin-zhicheng',
|
||||
name: '秦志成',
|
||||
title: '秦师傅',
|
||||
eras: '1978—2026',
|
||||
asset: '/assets/characters/qin-zhicheng.jpg',
|
||||
role: '从集体食堂小炊事员到桂香饭店老师傅,第十五桌的留下、挪走和回来都经过他的手。',
|
||||
visual: '宽头、粗颈、宽肩、厚实前臂;中式粗棉厨工褂、白围裙和低矮布帽。',
|
||||
},
|
||||
{
|
||||
id: 'lin-xiulan',
|
||||
name: '林秀兰',
|
||||
title: '票夹保管人',
|
||||
eras: '1978—2026',
|
||||
asset: '/assets/characters/lin-xiulan.jpg',
|
||||
role: '守住饭票、账本和钥匙,也守住每个人把旧事慢慢讲清楚的权利。',
|
||||
visual: '枣红或深棕工作外套;用动作摆事实,不竖指训人。',
|
||||
},
|
||||
{
|
||||
id: 'zhao-jianguo',
|
||||
name: '赵建国',
|
||||
title: '赵伯/劳动骨干',
|
||||
eras: '1978—2026',
|
||||
asset: '/assets/characters/zhao-jianguo.jpg',
|
||||
role: '年轻时把饭量当本事,后来学会不拿“为你好”替别人作决定。',
|
||||
visual: '青年工装结实,老年深蓝便帽、盖碗茶;豪爽但不被画成莽汉或病号。',
|
||||
},
|
||||
{
|
||||
id: 'tang-mingyuan',
|
||||
name: '唐明远',
|
||||
title: '中年家人',
|
||||
eras: '1995—2026',
|
||||
asset: '/assets/characters/tang-mingyuan.jpg',
|
||||
role: '唐守安之子、乐乐之父,用久坐、应酬、晚吃饭和家庭责任接住现代中年人的困境。',
|
||||
visual: '随年代逐渐出现腰腹与颈围变化;发福是生活轨迹,不作笑料或疾病诊断。',
|
||||
},
|
||||
{
|
||||
id: 'qin-xiaoman',
|
||||
name: '秦小满',
|
||||
title: '饭店经营者',
|
||||
eras: '2026',
|
||||
asset: '/assets/characters/qin-xiaoman.jpg',
|
||||
role: '把祖辈想留人的心意,转成纸单点餐、可选择份量和真实可用的座位。',
|
||||
visual: '简洁现代中式工作装,软尺、纸质座位单和调台记录随身。',
|
||||
},
|
||||
{
|
||||
id: 'lele',
|
||||
name: '乐乐',
|
||||
title: '会直问的小孙子',
|
||||
eras: '2026',
|
||||
asset: '/assets/characters/lele.jpg',
|
||||
role: '从儿童视角照见大人的生活习惯;教育对象是家庭环境,不把责任推给孩子。',
|
||||
visual: '圆润、活泼、讨人喜欢;不贴疾病、懒惰或减重标签。',
|
||||
},
|
||||
{
|
||||
id: 'xiaozhen',
|
||||
name: '小甄',
|
||||
title: '甄养堂健康客服',
|
||||
eras: '2026',
|
||||
asset: '/assets/characters/xiaozhen.jpg',
|
||||
role: '负责画外引导和知识回顾;需要时提醒联系专业医护,不替医生调整治疗。',
|
||||
visual: '灰绿色开衫、白衬衫、低马尾与记录夹;不作广告式主角。',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
const chapterTitles = [
|
||||
['2026', '开席前,少了一张桌'],
|
||||
['2026 → 1978', '铜牌背后的两张旧票'],
|
||||
['1978', '厂铃一响,饭盆就响'],
|
||||
['1978', '劳动骨干的第三碗饭'],
|
||||
['1978', '第十五桌给谁留'],
|
||||
['1993—1995', '木牌翻面的那一天'],
|
||||
['1995', '桂香饭馆开张'],
|
||||
['1998', '四凉八热才叫客气'],
|
||||
['2001', '一杯酒绕了三圈'],
|
||||
['2003', '后厨里的员工桌'],
|
||||
['2008', '旧房子要拆了'],
|
||||
['2026', '扫码点出一整桌'],
|
||||
['2026', '三代人都说“我是为你好”'],
|
||||
['2026', '秦师傅最后一道老菜'],
|
||||
['2026', '第十五桌重新开席'],
|
||||
]
|
||||
|
||||
module.exports = chapterTitles.map(([year, title], index) => ({
|
||||
number: index + 1,
|
||||
chapterId: `S01-C${String(index + 1).padStart(2, '0')}`,
|
||||
year,
|
||||
title,
|
||||
audioStatus: index === 0 ? 'audition' : 'reserved',
|
||||
}))
|
||||
@@ -0,0 +1,246 @@
|
||||
// Compact, main-package registry for the personal storybook.
|
||||
// The wording mirrors each chapter's reviewed memory page. Keep this file free
|
||||
// of gameplay logic so old collections can be restored without loading a game
|
||||
// subpackage.
|
||||
module.exports = [
|
||||
{
|
||||
cardId: 'S01-C01-MC01',
|
||||
reportId: 'S01-C01-RP01',
|
||||
chapterId: 'S01-C01',
|
||||
chapterNumber: 1,
|
||||
chapterTitle: '开席前,少了一张桌',
|
||||
characterName: '赵建国',
|
||||
eraLine: '2026 · 桂香大饭店宴会前厅',
|
||||
tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。',
|
||||
lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”',
|
||||
observation: '替家人安排得再周到,也别忘了问他的意思。',
|
||||
familySupport: '先问赵伯想坐哪里,再一起安排座位。',
|
||||
todayAction: '替家里人张罗之前,先问一句:“您想怎么来?”',
|
||||
familyLine: '下回别急着替我安排,先叫我一起商量。',
|
||||
eraObject: '手写请柬与十五号旧铜牌',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C02-MC01',
|
||||
reportId: 'S01-C02-RP01',
|
||||
chapterId: 'S01-C02',
|
||||
chapterNumber: 2,
|
||||
chapterTitle: '铜牌背后的两张旧票',
|
||||
characterName: '林秀兰',
|
||||
eraLine: '2026 → 1978 · 桂香旧物展柜',
|
||||
tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。',
|
||||
lifeAction: '保留近照和尺寸,等待旧桌板出现。',
|
||||
observation: '旧物和旧话都可能记错,慢一点核对更稳妥。',
|
||||
familySupport: '听林秀兰把旧事说完,再一起找照片和尺寸。',
|
||||
todayAction: '保留近照和尺寸,等待旧桌板出现。',
|
||||
familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。',
|
||||
eraObject: '十五号铜牌与两枚破损饭菜票',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C03-MC01',
|
||||
reportId: 'S01-C03-RP01',
|
||||
chapterId: 'S01-C03',
|
||||
chapterNumber: 3,
|
||||
chapterTitle: '厂铃一响,饭盆就响',
|
||||
characterName: '唐守安',
|
||||
eraLine: '1978 · 桂香机械厂集体食堂',
|
||||
tableEcho: '看见一个人,不只看他能干多少,也问他累不累。',
|
||||
lifeAction: '进食前按条件把手清洁并擦干',
|
||||
observation: '忙着干活的人,也需要被问一句累不累。',
|
||||
familySupport: '开饭前互相提醒洗手,也问问今天累不累。',
|
||||
todayAction: '进食前按条件把手清洁并擦干',
|
||||
familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?',
|
||||
eraObject: '饭票与搪瓷饭盒',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C04-MC01',
|
||||
reportId: 'S01-C04-RP01',
|
||||
chapterId: 'S01-C04',
|
||||
chapterNumber: 4,
|
||||
chapterTitle: '劳动骨干的第三碗饭',
|
||||
characterName: '赵建国',
|
||||
eraLine: '1978 · 桂香机械厂集体食堂',
|
||||
tableEcho: '饭能添,面子也要留;劝人停一停,先给他一张凳。',
|
||||
lifeAction: '停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。',
|
||||
observation: '劝人少添一碗,先留住他的体面和座位。',
|
||||
familySupport: '发现他不舒服,先让他坐下,再一起想办法。',
|
||||
todayAction: '停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。',
|
||||
familyLine: '下回我嘴硬时,先给我拉把凳子。',
|
||||
eraObject: '铝饭勺与长条凳',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C05-MC01',
|
||||
reportId: 'S01-C05-RP01',
|
||||
chapterId: 'S01-C05',
|
||||
chapterNumber: 5,
|
||||
chapterTitle: '第十五桌给谁留',
|
||||
characterName: '秦志成',
|
||||
eraLine: '1978 · 熄灯后的桂香食堂',
|
||||
tableEcho: '有人为晚归的人留了一盏灯。',
|
||||
lifeAction: '尽快说明情况,按单位安排和个人需要解决进食',
|
||||
observation: '晚归的人需要的不只是一口热饭,还有一个位置。',
|
||||
familySupport: '家里有人晚归,留句消息,也留一份合适的饭。',
|
||||
todayAction: '尽快说明情况,按单位安排和个人需要解决进食',
|
||||
familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?',
|
||||
eraObject: '第十五桌木牌与留饭灯',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C06-MC01',
|
||||
reportId: 'S01-C06-RP01',
|
||||
chapterId: 'S01-C06',
|
||||
chapterNumber: 6,
|
||||
chapterTitle: '木牌翻面的那一天',
|
||||
characterName: '秦志成',
|
||||
eraLine: '1995 · 从厂食堂改成的桂香饭馆',
|
||||
tableEcho: '门牌可以翻面,留给人的座位不能翻没。',
|
||||
lifeAction: '同时查看合同期限和资产清单',
|
||||
observation: '生意往前走,人情和规矩也要一起留下。',
|
||||
familySupport: '谈变化时,把期限、物件和家人的意见都摆明白。',
|
||||
todayAction: '同时查看合同期限和资产清单',
|
||||
familyLine: '回家聊一聊:饭馆要往前走,第十五桌怎样继续留下来?',
|
||||
eraObject: '翻面木牌与承包合同',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C07-MC01',
|
||||
reportId: 'S01-C07-RP01',
|
||||
chapterId: 'S01-C07',
|
||||
chapterNumber: 7,
|
||||
chapterTitle: '桂香饭馆开张',
|
||||
characterName: '唐明远',
|
||||
eraLine: '1995 · 开张后的桂香饭馆',
|
||||
tableEcho: '问出口的关心,要等到对方把话说完。',
|
||||
lifeAction: '一开始少盛,不够再添',
|
||||
observation: '问了“吃不吃”,也要等对方把话说完。',
|
||||
familySupport: '盛饭前先少一点,问清楚不够再添。',
|
||||
todayAction: '一开始少盛,不够再添',
|
||||
familyLine: '回家聊一聊:你愿意怎样把这句话接下去?',
|
||||
eraObject: '手写菜单与现金盒',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C08-MC01',
|
||||
reportId: 'S01-C08-RP01',
|
||||
chapterId: 'S01-C08',
|
||||
chapterNumber: 8,
|
||||
chapterTitle: '四凉八热才叫客气',
|
||||
characterName: '女炊事员',
|
||||
eraLine: '1998 · 桂香饭馆第一场大婚宴',
|
||||
tableEcho: '热闹照得到客人,也该照得到端菜的人。',
|
||||
lifeAction: '按人数、份量和菜品结构确认是否足够',
|
||||
observation: '热闹席面里,忙着端菜的人也值得被看见。',
|
||||
familySupport: '聚餐时问问照料大家的人有没有坐下吃饭。',
|
||||
todayAction: '按人数、份量和菜品结构确认是否足够',
|
||||
familyLine: '回家聊一聊:在最忙的时候,你想怎样让她知道自己没有被忘记?',
|
||||
eraObject: '红纸菜单与婚宴席单',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C09-MC01',
|
||||
reportId: 'S01-C09-RP01',
|
||||
chapterId: 'S01-C09',
|
||||
chapterNumber: 9,
|
||||
chapterTitle: '一杯酒绕了三圈',
|
||||
characterName: '赵建国',
|
||||
eraLine: '2001 · 桂香饭馆宴席',
|
||||
tableEcho: '照顾不是把人摘出去,是陪他把自己的选择说出来。',
|
||||
lifeAction: '明确告诉同桌自己要开车,直接换成茶或白水',
|
||||
observation: '照顾不是替他决定,而是让他把选择说出来。',
|
||||
familySupport: '不劝酒、不起哄,给赵伯茶或白水,也留住座位。',
|
||||
todayAction: '明确告诉同桌自己要开车,直接换成茶或白水',
|
||||
familyLine: '回家聊一聊:你愿意怎样让赵伯先说出自己的担心?',
|
||||
eraObject: '茶杯与小酒盅',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C10-MC01',
|
||||
reportId: 'S01-C10-RP01',
|
||||
chapterId: 'S01-C10',
|
||||
chapterNumber: 10,
|
||||
chapterTitle: '后厨里的员工桌',
|
||||
characterName: '唐明远',
|
||||
eraLine: '2003 · 桂香饭馆后厨',
|
||||
tableEcho: '桌上腾不出一只碗,忙碌就已经挤到人身上了。',
|
||||
lifeAction: '清出座位并排出轮班,让员工坐下完成一餐',
|
||||
observation: '忙到没地方坐下吃饭,已经不是小事。',
|
||||
familySupport: '一家人再忙,也给吃饭留出座位和时间。',
|
||||
todayAction: '清出座位并排出轮班,让员工坐下完成一餐',
|
||||
familyLine: '回家聊一聊:你想怎样帮他把这一顿饭重新放回生活里?',
|
||||
eraObject: '员工饭盒与后厨长桌',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C11-MC01',
|
||||
reportId: 'S01-C11-RP01',
|
||||
chapterId: 'S01-C11',
|
||||
chapterNumber: 11,
|
||||
chapterTitle: '旧房子要拆了',
|
||||
characterName: '秦志成',
|
||||
eraLine: '2008 · 翻建前的桂香旧房',
|
||||
tableEcho: '东西收进柜里叫保存,规矩留在人间才叫传下去。',
|
||||
lifeAction: '把理念落实到座位、菜单和服务动作',
|
||||
observation: '旧物保存下来,照顾人的规矩更要继续做。',
|
||||
familySupport: '把好意写成全家都能做到的小规矩。',
|
||||
todayAction: '把理念落实到座位、菜单和服务动作',
|
||||
familyLine: '回家聊一聊:除了保存旧物,你想请秦师傅再留下些什么?',
|
||||
eraObject: '旧桌板与暗红铁包边',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C12-MC01',
|
||||
reportId: 'S01-C12-RP01',
|
||||
chapterId: 'S01-C12',
|
||||
chapterNumber: 12,
|
||||
chapterTitle: '扫码点出一整桌',
|
||||
characterName: '赵建国',
|
||||
eraLine: '2026 · 重聚宴服务台与餐桌',
|
||||
tableEcho: '让人看懂、让人开口,才算把选择递到手里。',
|
||||
lifeAction: '为每项证据拍照并记录来源',
|
||||
observation: '信息看得懂、话说得出,选择才真正回到自己手里。',
|
||||
familySupport: '点餐时放慢一点,陪着看,不替老人直接下单。',
|
||||
todayAction: '为每项证据拍照并记录来源',
|
||||
familyLine: '回家聊一聊:信息都在了,最后一步怎样交还给赵伯?',
|
||||
eraObject: '手机点餐页与纸菜单',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C13-MC01',
|
||||
reportId: 'S01-C13-RP01',
|
||||
chapterId: 'S01-C13',
|
||||
chapterNumber: 13,
|
||||
chapterTitle: '三代人都说“我是为你好”',
|
||||
characterName: '乐乐',
|
||||
eraLine: '2026 · 重聚宴共同桌与侧桌',
|
||||
tableEcho: '这一次,大人终于听孩子把话说完。',
|
||||
lifeAction: '先介绍菜,让乐乐自己夹',
|
||||
observation: '关心孩子,也要先让孩子说出自己的感受。',
|
||||
familySupport: '介绍菜以后,先问乐乐想吃什么、想吃多少。',
|
||||
todayAction: '先介绍菜,让乐乐自己夹',
|
||||
familyLine: '回家聊一聊:这一回,家里人怎样让乐乐自己选?',
|
||||
eraObject: '公筷与儿童小碗',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C14-MC01',
|
||||
reportId: 'S01-C14-RP01',
|
||||
chapterId: 'S01-C14',
|
||||
chapterNumber: 14,
|
||||
chapterTitle: '秦师傅最后一道老菜',
|
||||
characterName: '唐守安',
|
||||
eraLine: '2026 · 重聚宴主桌',
|
||||
tableEcho: '会解决问题的人,也可以学着先听一会儿。',
|
||||
lifeAction: '只介绍食材、做法和份量信息',
|
||||
observation: '会给办法之前,先听完一句话。',
|
||||
familySupport: '家人开口时先不打断,听完再一起商量。',
|
||||
todayAction: '只介绍食材、做法和份量信息',
|
||||
familyLine: '回家聊一聊:唐守安怎样让儿子把那句旧话真正说完?',
|
||||
eraObject: '秦师傅的手写老菜谱',
|
||||
},
|
||||
{
|
||||
cardId: 'S01-C15-MC01',
|
||||
reportId: 'S01-C15-RP01',
|
||||
chapterId: 'S01-C15',
|
||||
chapterNumber: 15,
|
||||
chapterTitle: '第十五桌重新开席',
|
||||
characterName: '老吕',
|
||||
eraLine: '2026 · 桂香大饭店原址',
|
||||
tableEcho: '桌子回来了,人也都坐回来了。',
|
||||
lifeAction: '点餐前先看份量和人数',
|
||||
observation: '桌子回来只是开始,重要的是每个人都坐得自在。',
|
||||
familySupport: '点菜前先问人数、份量和每个人的想法。',
|
||||
todayAction: '点餐前先看份量和人数',
|
||||
familyLine: '回家聊一聊:这只旧碗怎样留在今天的第十五桌边?',
|
||||
eraObject: '缺口搪瓷碗与第十五桌铜牌',
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* This is the in-app build identity, not the version entered in WeChat
|
||||
* Developer Tools during upload. Keep semanticVersion aligned with the root
|
||||
* package.json so support screenshots can be traced back to source.
|
||||
*/
|
||||
module.exports = Object.freeze({
|
||||
productName: '唐侦探:桂香里的第十五桌',
|
||||
semanticVersion: '0.1.0',
|
||||
releaseChannel: 'release-candidate',
|
||||
contentVersion: 'season-01',
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
// First-chapter page tracks are packaged for an internal listening candidate.
|
||||
// They passed automated technical checks, but have not passed human listening,
|
||||
// elder-device listening or medical-content sign-off. Do not relabel approved.
|
||||
module.exports = Object.freeze({
|
||||
'S01-C01-P01': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 1,
|
||||
title: '开席前,少了一张桌',
|
||||
caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3',
|
||||
durationSeconds: 33.679388,
|
||||
sha256: 'daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282',
|
||||
}),
|
||||
'S01-C01-P02': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 2,
|
||||
title: '十五桌,到底去哪儿了?',
|
||||
caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3',
|
||||
durationSeconds: 49.49424,
|
||||
sha256: '7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2',
|
||||
}),
|
||||
'S01-C01-P03': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 3,
|
||||
title: '老人站在扫码牌前',
|
||||
caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3',
|
||||
durationSeconds: 30.534762,
|
||||
sha256: '0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885',
|
||||
}),
|
||||
'S01-C01-P04': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 4,
|
||||
title: '桌上已经够吃了',
|
||||
caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3',
|
||||
durationSeconds: 46.79424,
|
||||
sha256: 'ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167',
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,713 @@
|
||||
const audioPages = require('../../data/audioPages')
|
||||
|
||||
const SEEK_TIMEOUT_MS = 1600
|
||||
const PAUSE_LOCK_TIMEOUT_MS = 1200
|
||||
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
|
||||
const RATE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 0.8, label: '0.8倍' }),
|
||||
Object.freeze({ value: 1, label: '1.0倍' }),
|
||||
Object.freeze({ value: 1.2, label: '1.2倍' }),
|
||||
])
|
||||
const FALLBACK_RATE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 1, label: '1.0倍' }),
|
||||
])
|
||||
|
||||
function formatTime(value) {
|
||||
const seconds = Math.max(0, Math.floor(Number(value) || 0))
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
|
||||
}
|
||||
|
||||
function eventValue(event) {
|
||||
return Number(event && event.detail && event.detail.value) || 0
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageId: '',
|
||||
pageNumber: 1,
|
||||
title: '',
|
||||
caption: '',
|
||||
imageSrc: '',
|
||||
playing: false,
|
||||
loading: false,
|
||||
audioReady: false,
|
||||
hasStarted: false,
|
||||
seekLocked: false,
|
||||
error: '',
|
||||
currentLabel: '0:00',
|
||||
durationLabel: '0:00',
|
||||
durationSeconds: 1,
|
||||
sliderValue: 0,
|
||||
progressStyle: 'width:0%',
|
||||
playbackRate: 1,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
},
|
||||
|
||||
onLoad(options = {}) {
|
||||
this._unloaded = false
|
||||
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
|
||||
this._contextGeneration = 0
|
||||
this._intentGeneration = 0
|
||||
this._seekOperationGeneration = 0
|
||||
this._controlOperationGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._controlLocked = false
|
||||
this._scrubbing = false
|
||||
this._pendingSeek = null
|
||||
this._seekTimer = null
|
||||
this._pendingPauseLock = null
|
||||
this._pauseLockTimer = null
|
||||
this._page = null
|
||||
this.bindAudioInterruptionHandlers()
|
||||
const pageId = String(options.pageId || '')
|
||||
const page = audioPages[pageId]
|
||||
if (!page) {
|
||||
this.setData({ error: '这一页的声音没有找到,请返回连环画。' })
|
||||
return
|
||||
}
|
||||
this._page = page
|
||||
const durationSeconds = Math.max(1, Number(page.durationSeconds) || 1)
|
||||
this.setData({
|
||||
pageId,
|
||||
pageNumber: page.pageNumber,
|
||||
title: page.title,
|
||||
caption: page.caption,
|
||||
imageSrc: page.imageSrc,
|
||||
durationLabel: formatTime(durationSeconds),
|
||||
durationSeconds,
|
||||
})
|
||||
},
|
||||
|
||||
onReady() {
|
||||
if (!this._page || this._unloaded) return
|
||||
this.createAudioContext(this._page.audioSrc)
|
||||
},
|
||||
|
||||
isCurrentContext(context, generation) {
|
||||
return !this._unloaded
|
||||
&& this.audioContext === context
|
||||
&& this._contextGeneration === generation
|
||||
},
|
||||
|
||||
setProgress(value, durationValue) {
|
||||
const duration = Math.max(
|
||||
1,
|
||||
Number(durationValue) || Number(this.data.durationSeconds) || 1,
|
||||
)
|
||||
const current = clamp(value, 0, duration)
|
||||
const percent = Math.min(100, current / duration * 100)
|
||||
this.setData({
|
||||
currentLabel: formatTime(current),
|
||||
durationLabel: formatTime(duration),
|
||||
durationSeconds: duration,
|
||||
sliderValue: current,
|
||||
progressStyle: `width:${percent}%`,
|
||||
})
|
||||
},
|
||||
|
||||
disableRateControls(context) {
|
||||
try {
|
||||
if (context && typeof context.playbackRate === 'number') {
|
||||
context.playbackRate = 1
|
||||
}
|
||||
} catch (_) {
|
||||
// A runtime that rejects playbackRate remains safely at normal speed.
|
||||
}
|
||||
this.setData({
|
||||
playbackRate: 1,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
})
|
||||
},
|
||||
|
||||
configureRateControls(context, generation) {
|
||||
try {
|
||||
if (typeof context.playbackRate !== 'number') {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
for (const rate of RATE_VALUES) {
|
||||
context.playbackRate = rate
|
||||
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
}
|
||||
context.playbackRate = 1
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
|
||||
) {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
playbackRate: 1,
|
||||
rateOptions: RATE_OPTIONS,
|
||||
rateControlsVisible: true,
|
||||
})
|
||||
} catch (_) {
|
||||
this.disableRateControls(context)
|
||||
}
|
||||
},
|
||||
|
||||
createAudioContext(src) {
|
||||
if (this._unloaded) return null
|
||||
if (this.audioContext) this.destroyAudioContext()
|
||||
const context = wx.createInnerAudioContext()
|
||||
const generation = this._contextGeneration + 1
|
||||
this._contextGeneration = generation
|
||||
this.audioContext = context
|
||||
context.autoplay = false
|
||||
context.src = src
|
||||
this.setData({
|
||||
audioReady: true,
|
||||
loading: false,
|
||||
playing: false,
|
||||
error: '',
|
||||
})
|
||||
this.configureRateControls(context, generation)
|
||||
|
||||
context.onCanplay(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.setData({ audioReady: true, error: '' })
|
||||
})
|
||||
context.onPlay(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
if (
|
||||
!this._desiredPlayback
|
||||
|| this._activePlayIntentGeneration !== this._intentGeneration
|
||||
) {
|
||||
context.pause()
|
||||
this._controlLocked = false
|
||||
this.setData({ playing: false, loading: false })
|
||||
return
|
||||
}
|
||||
this._controlLocked = false
|
||||
this.setData({
|
||||
playing: true,
|
||||
loading: false,
|
||||
hasStarted: true,
|
||||
error: '',
|
||||
})
|
||||
})
|
||||
context.onPause(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
if (this._desiredPlayback) return
|
||||
this.releasePauseLockFromEvent(context, generation)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onStop(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onWaiting(() => {
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| !this._desiredPlayback
|
||||
) return
|
||||
this.setData({ playing: false, loading: true })
|
||||
})
|
||||
context.onEnded(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onTimeUpdate(() => {
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| this._scrubbing
|
||||
|| this._pendingSeek
|
||||
) return
|
||||
const duration = Number(context.duration) || this.data.durationSeconds
|
||||
const current = Number(context.currentTime) || 0
|
||||
this.setProgress(current, duration)
|
||||
})
|
||||
if (typeof context.onSeeked === 'function') {
|
||||
context.onSeeked(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.finishSeekFromEvent(context, generation)
|
||||
})
|
||||
}
|
||||
context.onError(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({
|
||||
playing: false,
|
||||
loading: false,
|
||||
error: '声音暂时没打开,您可以返回继续看连环画。',
|
||||
})
|
||||
})
|
||||
this.bindAudioInterruptionHandlers()
|
||||
return context
|
||||
},
|
||||
|
||||
beginPlaybackIntent() {
|
||||
this._intentGeneration += 1
|
||||
this._desiredPlayback = true
|
||||
this._activePlayIntentGeneration = this._intentGeneration
|
||||
return this._intentGeneration
|
||||
},
|
||||
|
||||
clearSeekState(updateData = true) {
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._seekOperationGeneration += 1
|
||||
this._pendingSeek = null
|
||||
this._scrubbing = false
|
||||
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
|
||||
},
|
||||
|
||||
clearPauseLock(unlock = true) {
|
||||
if (this._pauseLockTimer !== null) {
|
||||
clearTimeout(this._pauseLockTimer)
|
||||
this._pauseLockTimer = null
|
||||
}
|
||||
this._controlOperationGeneration += 1
|
||||
this._pendingPauseLock = null
|
||||
if (unlock) this._controlLocked = false
|
||||
},
|
||||
|
||||
beginPauseLock(context) {
|
||||
this.clearPauseLock(true)
|
||||
const operationGeneration = this._controlOperationGeneration + 1
|
||||
this._controlOperationGeneration = operationGeneration
|
||||
const pending = {
|
||||
operationGeneration,
|
||||
contextGeneration: this._contextGeneration,
|
||||
intentGeneration: this._intentGeneration,
|
||||
lifecycleGeneration: this._lifecycleGeneration,
|
||||
context,
|
||||
}
|
||||
this._pendingPauseLock = pending
|
||||
this._controlLocked = true
|
||||
this._pauseLockTimer = setTimeout(() => {
|
||||
this.releasePauseLock(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
pending.lifecycleGeneration,
|
||||
context,
|
||||
)
|
||||
}, PAUSE_LOCK_TIMEOUT_MS)
|
||||
},
|
||||
|
||||
releasePauseLock(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
lifecycleGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingPauseLock
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.lifecycleGeneration !== lifecycleGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
|| this._lifecycleGeneration !== lifecycleGeneration
|
||||
|| this._desiredPlayback
|
||||
) return false
|
||||
if (this._pauseLockTimer !== null) {
|
||||
clearTimeout(this._pauseLockTimer)
|
||||
this._pauseLockTimer = null
|
||||
}
|
||||
this._pendingPauseLock = null
|
||||
this._controlLocked = false
|
||||
return true
|
||||
},
|
||||
|
||||
releasePauseLockFromEvent(context, contextGeneration) {
|
||||
const pending = this._pendingPauseLock
|
||||
if (!pending) {
|
||||
if (this.isCurrentContext(context, contextGeneration)) {
|
||||
this._controlLocked = false
|
||||
}
|
||||
return
|
||||
}
|
||||
this.releasePauseLock(
|
||||
pending.operationGeneration,
|
||||
contextGeneration,
|
||||
pending.intentGeneration,
|
||||
pending.lifecycleGeneration,
|
||||
context,
|
||||
)
|
||||
},
|
||||
|
||||
invalidatePlaybackIntent(pauseContext = true) {
|
||||
this._intentGeneration += 1
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this.clearPauseLock(true)
|
||||
this.clearSeekState(!this._unloaded)
|
||||
if (
|
||||
pauseContext
|
||||
&& this.audioContext
|
||||
&& typeof this.audioContext.pause === 'function'
|
||||
) {
|
||||
this.audioContext.pause()
|
||||
}
|
||||
},
|
||||
|
||||
startPlaybackWithIntent(intentGeneration) {
|
||||
const context = this.audioContext
|
||||
if (
|
||||
!context
|
||||
|| this._unloaded
|
||||
|| intentGeneration !== this._intentGeneration
|
||||
|| !this._desiredPlayback
|
||||
) return false
|
||||
this._controlLocked = true
|
||||
this.setData({
|
||||
loading: true,
|
||||
playing: false,
|
||||
hasStarted: true,
|
||||
error: '',
|
||||
})
|
||||
context.play()
|
||||
return true
|
||||
},
|
||||
|
||||
bindAudioInterruptionHandlers() {
|
||||
if (this._audioInterruptionBeginHandler) return
|
||||
const generation = this._lifecycleGeneration
|
||||
this._audioInterruptionBeginHandler = () => {
|
||||
if (this._unloaded || this._lifecycleGeneration !== generation) return
|
||||
this.pauseWithoutAutoResume()
|
||||
}
|
||||
this._audioInterruptionEndHandler = () => {
|
||||
if (this._unloaded || this._lifecycleGeneration !== generation) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({ playing: false, loading: false })
|
||||
}
|
||||
if (typeof wx.onAudioInterruptionBegin === 'function') {
|
||||
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
|
||||
}
|
||||
if (typeof wx.onAudioInterruptionEnd === 'function') {
|
||||
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
|
||||
}
|
||||
},
|
||||
|
||||
unbindAudioInterruptionHandlers() {
|
||||
if (
|
||||
this._audioInterruptionBeginHandler
|
||||
&& typeof wx.offAudioInterruptionBegin === 'function'
|
||||
) {
|
||||
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
|
||||
}
|
||||
if (
|
||||
this._audioInterruptionEndHandler
|
||||
&& typeof wx.offAudioInterruptionEnd === 'function'
|
||||
) {
|
||||
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
|
||||
}
|
||||
this._audioInterruptionBeginHandler = null
|
||||
this._audioInterruptionEndHandler = null
|
||||
},
|
||||
|
||||
pauseWithoutAutoResume() {
|
||||
const context = this.audioContext
|
||||
this.invalidatePlaybackIntent(false)
|
||||
if (context && typeof context.pause === 'function') {
|
||||
this.beginPauseLock(context)
|
||||
try {
|
||||
context.pause()
|
||||
} catch (_) {
|
||||
this.clearPauseLock(true)
|
||||
}
|
||||
}
|
||||
if (!this._unloaded) {
|
||||
this.setData({ playing: false, loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
onHide() {
|
||||
this.pauseWithoutAutoResume()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (this._unloaded) return
|
||||
this.clearPauseLock(true)
|
||||
this.setData({ playing: false, loading: false, seekLocked: false })
|
||||
},
|
||||
|
||||
togglePlayback() {
|
||||
if (this.data.loading || this._controlLocked || this._unloaded) return
|
||||
if (!this.audioContext && this._page) {
|
||||
this.createAudioContext(this._page.audioSrc)
|
||||
}
|
||||
if (!this.audioContext) return
|
||||
if (this.data.playing || this._desiredPlayback) {
|
||||
this.pauseWithoutAutoResume()
|
||||
return
|
||||
}
|
||||
const intentGeneration = this.beginPlaybackIntent()
|
||||
this.startPlaybackWithIntent(intentGeneration)
|
||||
},
|
||||
|
||||
requestSeek(targetValue, options = {}) {
|
||||
const context = this.audioContext
|
||||
if (
|
||||
!context
|
||||
|| this._unloaded
|
||||
|| this.data.loading
|
||||
|| this._pendingSeek
|
||||
) return false
|
||||
const duration = Math.max(
|
||||
1,
|
||||
Number(context.duration) || Number(this.data.durationSeconds) || 1,
|
||||
)
|
||||
const target = clamp(targetValue, 0, duration)
|
||||
const actualBeforeSeek = Number(context.currentTime)
|
||||
const previousValue = clamp(
|
||||
Number.isFinite(actualBeforeSeek)
|
||||
? actualBeforeSeek
|
||||
: Number(this.data.sliderValue) || 0,
|
||||
0,
|
||||
duration,
|
||||
)
|
||||
const operationGeneration = this._seekOperationGeneration + 1
|
||||
this._seekOperationGeneration = operationGeneration
|
||||
const pending = {
|
||||
operationGeneration,
|
||||
contextGeneration: this._contextGeneration,
|
||||
intentGeneration: this._intentGeneration,
|
||||
context,
|
||||
target,
|
||||
previousValue,
|
||||
playAfterSeek: Boolean(options.playAfterSeek),
|
||||
}
|
||||
this._pendingSeek = pending
|
||||
this._scrubbing = false
|
||||
this.setProgress(target, duration)
|
||||
this.setData({ seekLocked: true, error: '' })
|
||||
this._seekTimer = setTimeout(() => {
|
||||
this.failSeek(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
}, SEEK_TIMEOUT_MS)
|
||||
try {
|
||||
context.seek(target)
|
||||
} catch (_) {
|
||||
this.failSeek(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
finishSeekFromEvent(context, contextGeneration) {
|
||||
const pending = this._pendingSeek
|
||||
if (!pending) return
|
||||
const actual = Number(context.currentTime)
|
||||
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
|
||||
this.finishSeek(
|
||||
pending.operationGeneration,
|
||||
contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
},
|
||||
|
||||
finishSeek(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingSeek
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
) return
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._pendingSeek = null
|
||||
this.setData({ seekLocked: false })
|
||||
if (
|
||||
pending.playAfterSeek
|
||||
&& this._desiredPlayback
|
||||
&& this._activePlayIntentGeneration === intentGeneration
|
||||
) {
|
||||
this.startPlaybackWithIntent(intentGeneration)
|
||||
}
|
||||
},
|
||||
|
||||
failSeek(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingSeek
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
) return false
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._pendingSeek = null
|
||||
this._scrubbing = false
|
||||
const actual = Number(context.currentTime)
|
||||
const restoredValue = Number.isFinite(actual)
|
||||
? actual
|
||||
: pending.previousValue
|
||||
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
|
||||
this.setProgress(restoredValue, this.data.durationSeconds)
|
||||
this.setData({
|
||||
playing: pending.playAfterSeek ? false : this.data.playing,
|
||||
loading: false,
|
||||
seekLocked: false,
|
||||
error: '进度没有调好,请再试一次。',
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
previewSeek(event) {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
this._scrubbing = true
|
||||
this.setProgress(eventValue(event), this.data.durationSeconds)
|
||||
},
|
||||
|
||||
commitSeek(event) {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
this._scrubbing = false
|
||||
this.requestSeek(eventValue(event))
|
||||
},
|
||||
|
||||
rewindTenSeconds() {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
const current = Number(this.audioContext.currentTime)
|
||||
const fallback = Number(this.data.sliderValue) || 0
|
||||
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
|
||||
},
|
||||
|
||||
replay() {
|
||||
if (
|
||||
!this.audioContext
|
||||
|| this.data.loading
|
||||
|| this._controlLocked
|
||||
|| this._pendingSeek
|
||||
|| this._unloaded
|
||||
) return
|
||||
const alreadyPlaying = this.data.playing && this._desiredPlayback
|
||||
const intentGeneration = alreadyPlaying
|
||||
? this._intentGeneration
|
||||
: this.beginPlaybackIntent()
|
||||
this.setData({ hasStarted: true, error: '' })
|
||||
this.requestSeek(0, {
|
||||
playAfterSeek: !alreadyPlaying,
|
||||
intentGeneration,
|
||||
})
|
||||
},
|
||||
|
||||
changePlaybackRate(event) {
|
||||
const requested = Number(
|
||||
event && event.currentTarget && event.currentTarget.dataset.rate,
|
||||
)
|
||||
if (
|
||||
!RATE_VALUES.includes(requested)
|
||||
|| !this.audioContext
|
||||
|| !this.data.rateControlsVisible
|
||||
) return
|
||||
const allowed = this.data.rateOptions.some((item) => item.value === requested)
|
||||
if (!allowed) return
|
||||
try {
|
||||
this.audioContext.playbackRate = requested
|
||||
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
|
||||
this.disableRateControls(this.audioContext)
|
||||
return
|
||||
}
|
||||
this.setData({ playbackRate: requested })
|
||||
} catch (_) {
|
||||
this.disableRateControls(this.audioContext)
|
||||
}
|
||||
},
|
||||
|
||||
goBack() {
|
||||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
|
||||
const returnToChapter = () => {
|
||||
if (typeof wx.reLaunch === 'function') {
|
||||
wx.reLaunch({
|
||||
url: '/package-game/pages/chapter/chapter?chapter=1',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
|
||||
wx.navigateBack({ delta: 1, fail: returnToChapter })
|
||||
return
|
||||
}
|
||||
returnToChapter()
|
||||
},
|
||||
|
||||
destroyAudioContext() {
|
||||
const context = this.audioContext
|
||||
if (!context) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this._contextGeneration += 1
|
||||
this.audioContext = null
|
||||
context.destroy()
|
||||
if (!this._unloaded) {
|
||||
this.setData({
|
||||
audioReady: false,
|
||||
playing: false,
|
||||
loading: false,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
playbackRate: 1,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._unloaded = true
|
||||
this._lifecycleGeneration += 1
|
||||
this._intentGeneration += 1
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this._controlLocked = true
|
||||
this.clearPauseLock(false)
|
||||
this.clearSeekState(false)
|
||||
this.unbindAudioInterruptionHandlers()
|
||||
const context = this.audioContext
|
||||
this._contextGeneration += 1
|
||||
this.audioContext = null
|
||||
if (context) context.destroy()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<view class="audio-leaf">
|
||||
<view class="audio-leaf-topbar">
|
||||
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画">‹ 返回连环画</button>
|
||||
<view class="leaf-heading">
|
||||
<text class="leaf-kicker">第01回 · 有声夹页</text>
|
||||
<text class="leaf-count">{{pageNumber}}/8</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="audio-leaf-spread">
|
||||
<view class="leaf-art-frame">
|
||||
<image class="leaf-art" src="{{imageSrc}}" mode="aspectFit"></image>
|
||||
<text class="leaf-stamp">桂香故事</text>
|
||||
</view>
|
||||
|
||||
<view class="leaf-copy">
|
||||
<text class="leaf-title">{{title}}</text>
|
||||
<text class="leaf-caption">{{caption}}</text>
|
||||
|
||||
<view class="leaf-seek-row">
|
||||
<slider
|
||||
class="leaf-slider"
|
||||
aria-label="播放进度,可左右拖动"
|
||||
min="0"
|
||||
max="{{durationSeconds}}"
|
||||
step="1"
|
||||
value="{{sliderValue}}"
|
||||
activeColor="#9b3328"
|
||||
backgroundColor="#d6c6a1"
|
||||
block-color="#8f271f"
|
||||
block-size="24"
|
||||
disabled="{{loading || !audioReady || seekLocked}}"
|
||||
bindchanging="previewSeek"
|
||||
bindchange="commitSeek"
|
||||
></slider>
|
||||
<text class="leaf-time">{{currentLabel}} / {{durationLabel}}</text>
|
||||
</view>
|
||||
|
||||
<view class="leaf-controls">
|
||||
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
|
||||
<button class="leaf-button primary" disabled="{{loading || !audioReady || seekLocked}}" bindtap="togglePlayback">
|
||||
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
|
||||
</button>
|
||||
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{rateControlsVisible}}" class="leaf-rate-row" aria-label="播放速度">
|
||||
<text class="leaf-rate-label">语速</text>
|
||||
<button
|
||||
wx:for="{{rateOptions}}"
|
||||
wx:key="value"
|
||||
class="leaf-rate-button {{playbackRate == item.value ? 'active' : ''}}"
|
||||
data-rate="{{item.value}}"
|
||||
disabled="{{loading || seekLocked}}"
|
||||
bindtap="changePlaybackRate"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
|
||||
<text wx:if="{{error}}" class="leaf-error">{{error}}</text>
|
||||
<button class="back-bottom" bindtap="goBack">看完,回到连环画</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,171 @@
|
||||
page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: #1c130f;
|
||||
color: #2f241d;
|
||||
}
|
||||
|
||||
button::after { border: 0; }
|
||||
|
||||
.audio-leaf {
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 320px;
|
||||
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: radial-gradient(circle at 48% 45%, #423024 0, #211611 70%);
|
||||
}
|
||||
|
||||
.audio-leaf-topbar {
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding-right: max(92px, env(safe-area-inset-right));
|
||||
color: #f5e6b7;
|
||||
}
|
||||
|
||||
.back-top {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 48px;
|
||||
border: 1px solid #9b7a51;
|
||||
border-radius: 10px;
|
||||
background: #38251b;
|
||||
color: #f7e8bd;
|
||||
font-size: 20px;
|
||||
line-height: 48px;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.leaf-heading { display: flex; align-items: baseline; gap: 12px; }
|
||||
.leaf-kicker { font-size: 20px; font-weight: 700; }
|
||||
.leaf-count { color: #d9bb78; font-size: 18px; }
|
||||
|
||||
.audio-leaf-spread {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
|
||||
border: 2px solid #b99762;
|
||||
background: #efe2bd;
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
|
||||
}
|
||||
|
||||
.leaf-art-frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #17100c;
|
||||
border-right: 4px solid #8d271f;
|
||||
}
|
||||
|
||||
.leaf-art { width: 100%; height: 100%; display: block; }
|
||||
.leaf-stamp {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: 16px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(86, 24, 20, .9);
|
||||
color: #f5e6bd;
|
||||
font-size: 17px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.leaf-copy {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 18px 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
|
||||
}
|
||||
|
||||
.leaf-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
|
||||
.leaf-caption { margin-top: 10px; font-size: 22px; font-weight: 600; line-height: 1.48; }
|
||||
.leaf-seek-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.leaf-slider { min-width: 0; width: 100%; margin: 0; }
|
||||
.leaf-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
|
||||
.leaf-controls {
|
||||
min-width: 0;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.leaf-button, .back-bottom {
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
border: 2px solid #7e6648;
|
||||
border-radius: 10px;
|
||||
background: #eadbb5;
|
||||
color: #37291f;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.leaf-button { width: 100%; max-width: 100%; }
|
||||
.leaf-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
|
||||
.leaf-button[disabled] { opacity: .72; }
|
||||
.leaf-rate-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.leaf-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
|
||||
.leaf-rate-button {
|
||||
box-sizing: border-box;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border: 2px solid #9d896a;
|
||||
border-radius: 9px;
|
||||
background: #f3e7c8;
|
||||
color: #4d3b2d;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
}
|
||||
.leaf-rate-button { width: 100%; max-width: 100%; }
|
||||
.leaf-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
|
||||
.leaf-rate-button[disabled] { opacity: .72; }
|
||||
.leaf-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
|
||||
.back-bottom { margin-top: 8px; width: 100%; }
|
||||
|
||||
@media (max-height: 430px) {
|
||||
.audio-leaf { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
|
||||
.audio-leaf-topbar { height: 48px; flex-basis: 48px; }
|
||||
.leaf-copy { padding: 8px 12px; overflow-y: auto; }
|
||||
.leaf-title { font-size: 23px; }
|
||||
.leaf-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
|
||||
.leaf-seek-row { min-height: 48px; }
|
||||
.leaf-controls, .leaf-rate-row { margin-top: 3px; gap: 5px; }
|
||||
.leaf-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
|
||||
.leaf-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
|
||||
.leaf-rate-label { font-size: 16px; }
|
||||
.back-bottom { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// First-chapter page tracks are packaged for an internal listening candidate.
|
||||
// They passed automated technical checks, but have not passed human listening,
|
||||
// elder-device listening or medical-content sign-off. Do not relabel approved.
|
||||
module.exports = Object.freeze({
|
||||
'S01-C01-P05': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 5,
|
||||
title: '地上留下四个新压痕',
|
||||
caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。',
|
||||
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3',
|
||||
durationSeconds: 32.880567,
|
||||
sha256: '210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a',
|
||||
}),
|
||||
'S01-C01-P06': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 6,
|
||||
title: '秦师傅把话咽了回去',
|
||||
caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。',
|
||||
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3',
|
||||
durationSeconds: 13.328889,
|
||||
sha256: '5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4',
|
||||
}),
|
||||
'S01-C01-P07': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 7,
|
||||
title: '把赵伯叫进来',
|
||||
caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。',
|
||||
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3',
|
||||
durationSeconds: 10.466644,
|
||||
sha256: 'a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5',
|
||||
}),
|
||||
'S01-C01-P08': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 8,
|
||||
title: '锅盖响了一声',
|
||||
caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。',
|
||||
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3',
|
||||
durationSeconds: 39.534467,
|
||||
sha256: '1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec',
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,713 @@
|
||||
const audioPages = require('../../data/audioPages')
|
||||
|
||||
const SEEK_TIMEOUT_MS = 1600
|
||||
const PAUSE_LOCK_TIMEOUT_MS = 1200
|
||||
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
|
||||
const RATE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 0.8, label: '0.8倍' }),
|
||||
Object.freeze({ value: 1, label: '1.0倍' }),
|
||||
Object.freeze({ value: 1.2, label: '1.2倍' }),
|
||||
])
|
||||
const FALLBACK_RATE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 1, label: '1.0倍' }),
|
||||
])
|
||||
|
||||
function formatTime(value) {
|
||||
const seconds = Math.max(0, Math.floor(Number(value) || 0))
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
|
||||
}
|
||||
|
||||
function eventValue(event) {
|
||||
return Number(event && event.detail && event.detail.value) || 0
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageId: '',
|
||||
pageNumber: 1,
|
||||
title: '',
|
||||
caption: '',
|
||||
imageSrc: '',
|
||||
playing: false,
|
||||
loading: false,
|
||||
audioReady: false,
|
||||
hasStarted: false,
|
||||
seekLocked: false,
|
||||
error: '',
|
||||
currentLabel: '0:00',
|
||||
durationLabel: '0:00',
|
||||
durationSeconds: 1,
|
||||
sliderValue: 0,
|
||||
progressStyle: 'width:0%',
|
||||
playbackRate: 1,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
},
|
||||
|
||||
onLoad(options = {}) {
|
||||
this._unloaded = false
|
||||
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
|
||||
this._contextGeneration = 0
|
||||
this._intentGeneration = 0
|
||||
this._seekOperationGeneration = 0
|
||||
this._controlOperationGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._controlLocked = false
|
||||
this._scrubbing = false
|
||||
this._pendingSeek = null
|
||||
this._seekTimer = null
|
||||
this._pendingPauseLock = null
|
||||
this._pauseLockTimer = null
|
||||
this._page = null
|
||||
this.bindAudioInterruptionHandlers()
|
||||
const pageId = String(options.pageId || '')
|
||||
const page = audioPages[pageId]
|
||||
if (!page) {
|
||||
this.setData({ error: '这一页的声音没有找到,请返回连环画。' })
|
||||
return
|
||||
}
|
||||
this._page = page
|
||||
const durationSeconds = Math.max(1, Number(page.durationSeconds) || 1)
|
||||
this.setData({
|
||||
pageId,
|
||||
pageNumber: page.pageNumber,
|
||||
title: page.title,
|
||||
caption: page.caption,
|
||||
imageSrc: page.imageSrc,
|
||||
durationLabel: formatTime(durationSeconds),
|
||||
durationSeconds,
|
||||
})
|
||||
},
|
||||
|
||||
onReady() {
|
||||
if (!this._page || this._unloaded) return
|
||||
this.createAudioContext(this._page.audioSrc)
|
||||
},
|
||||
|
||||
isCurrentContext(context, generation) {
|
||||
return !this._unloaded
|
||||
&& this.audioContext === context
|
||||
&& this._contextGeneration === generation
|
||||
},
|
||||
|
||||
setProgress(value, durationValue) {
|
||||
const duration = Math.max(
|
||||
1,
|
||||
Number(durationValue) || Number(this.data.durationSeconds) || 1,
|
||||
)
|
||||
const current = clamp(value, 0, duration)
|
||||
const percent = Math.min(100, current / duration * 100)
|
||||
this.setData({
|
||||
currentLabel: formatTime(current),
|
||||
durationLabel: formatTime(duration),
|
||||
durationSeconds: duration,
|
||||
sliderValue: current,
|
||||
progressStyle: `width:${percent}%`,
|
||||
})
|
||||
},
|
||||
|
||||
disableRateControls(context) {
|
||||
try {
|
||||
if (context && typeof context.playbackRate === 'number') {
|
||||
context.playbackRate = 1
|
||||
}
|
||||
} catch (_) {
|
||||
// A runtime that rejects playbackRate remains safely at normal speed.
|
||||
}
|
||||
this.setData({
|
||||
playbackRate: 1,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
})
|
||||
},
|
||||
|
||||
configureRateControls(context, generation) {
|
||||
try {
|
||||
if (typeof context.playbackRate !== 'number') {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
for (const rate of RATE_VALUES) {
|
||||
context.playbackRate = rate
|
||||
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
}
|
||||
context.playbackRate = 1
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
|
||||
) {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
playbackRate: 1,
|
||||
rateOptions: RATE_OPTIONS,
|
||||
rateControlsVisible: true,
|
||||
})
|
||||
} catch (_) {
|
||||
this.disableRateControls(context)
|
||||
}
|
||||
},
|
||||
|
||||
createAudioContext(src) {
|
||||
if (this._unloaded) return null
|
||||
if (this.audioContext) this.destroyAudioContext()
|
||||
const context = wx.createInnerAudioContext()
|
||||
const generation = this._contextGeneration + 1
|
||||
this._contextGeneration = generation
|
||||
this.audioContext = context
|
||||
context.autoplay = false
|
||||
context.src = src
|
||||
this.setData({
|
||||
audioReady: true,
|
||||
loading: false,
|
||||
playing: false,
|
||||
error: '',
|
||||
})
|
||||
this.configureRateControls(context, generation)
|
||||
|
||||
context.onCanplay(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.setData({ audioReady: true, error: '' })
|
||||
})
|
||||
context.onPlay(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
if (
|
||||
!this._desiredPlayback
|
||||
|| this._activePlayIntentGeneration !== this._intentGeneration
|
||||
) {
|
||||
context.pause()
|
||||
this._controlLocked = false
|
||||
this.setData({ playing: false, loading: false })
|
||||
return
|
||||
}
|
||||
this._controlLocked = false
|
||||
this.setData({
|
||||
playing: true,
|
||||
loading: false,
|
||||
hasStarted: true,
|
||||
error: '',
|
||||
})
|
||||
})
|
||||
context.onPause(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
if (this._desiredPlayback) return
|
||||
this.releasePauseLockFromEvent(context, generation)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onStop(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onWaiting(() => {
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| !this._desiredPlayback
|
||||
) return
|
||||
this.setData({ playing: false, loading: true })
|
||||
})
|
||||
context.onEnded(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onTimeUpdate(() => {
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| this._scrubbing
|
||||
|| this._pendingSeek
|
||||
) return
|
||||
const duration = Number(context.duration) || this.data.durationSeconds
|
||||
const current = Number(context.currentTime) || 0
|
||||
this.setProgress(current, duration)
|
||||
})
|
||||
if (typeof context.onSeeked === 'function') {
|
||||
context.onSeeked(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.finishSeekFromEvent(context, generation)
|
||||
})
|
||||
}
|
||||
context.onError(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({
|
||||
playing: false,
|
||||
loading: false,
|
||||
error: '声音暂时没打开,您可以返回继续看连环画。',
|
||||
})
|
||||
})
|
||||
this.bindAudioInterruptionHandlers()
|
||||
return context
|
||||
},
|
||||
|
||||
beginPlaybackIntent() {
|
||||
this._intentGeneration += 1
|
||||
this._desiredPlayback = true
|
||||
this._activePlayIntentGeneration = this._intentGeneration
|
||||
return this._intentGeneration
|
||||
},
|
||||
|
||||
clearSeekState(updateData = true) {
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._seekOperationGeneration += 1
|
||||
this._pendingSeek = null
|
||||
this._scrubbing = false
|
||||
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
|
||||
},
|
||||
|
||||
clearPauseLock(unlock = true) {
|
||||
if (this._pauseLockTimer !== null) {
|
||||
clearTimeout(this._pauseLockTimer)
|
||||
this._pauseLockTimer = null
|
||||
}
|
||||
this._controlOperationGeneration += 1
|
||||
this._pendingPauseLock = null
|
||||
if (unlock) this._controlLocked = false
|
||||
},
|
||||
|
||||
beginPauseLock(context) {
|
||||
this.clearPauseLock(true)
|
||||
const operationGeneration = this._controlOperationGeneration + 1
|
||||
this._controlOperationGeneration = operationGeneration
|
||||
const pending = {
|
||||
operationGeneration,
|
||||
contextGeneration: this._contextGeneration,
|
||||
intentGeneration: this._intentGeneration,
|
||||
lifecycleGeneration: this._lifecycleGeneration,
|
||||
context,
|
||||
}
|
||||
this._pendingPauseLock = pending
|
||||
this._controlLocked = true
|
||||
this._pauseLockTimer = setTimeout(() => {
|
||||
this.releasePauseLock(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
pending.lifecycleGeneration,
|
||||
context,
|
||||
)
|
||||
}, PAUSE_LOCK_TIMEOUT_MS)
|
||||
},
|
||||
|
||||
releasePauseLock(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
lifecycleGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingPauseLock
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.lifecycleGeneration !== lifecycleGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
|| this._lifecycleGeneration !== lifecycleGeneration
|
||||
|| this._desiredPlayback
|
||||
) return false
|
||||
if (this._pauseLockTimer !== null) {
|
||||
clearTimeout(this._pauseLockTimer)
|
||||
this._pauseLockTimer = null
|
||||
}
|
||||
this._pendingPauseLock = null
|
||||
this._controlLocked = false
|
||||
return true
|
||||
},
|
||||
|
||||
releasePauseLockFromEvent(context, contextGeneration) {
|
||||
const pending = this._pendingPauseLock
|
||||
if (!pending) {
|
||||
if (this.isCurrentContext(context, contextGeneration)) {
|
||||
this._controlLocked = false
|
||||
}
|
||||
return
|
||||
}
|
||||
this.releasePauseLock(
|
||||
pending.operationGeneration,
|
||||
contextGeneration,
|
||||
pending.intentGeneration,
|
||||
pending.lifecycleGeneration,
|
||||
context,
|
||||
)
|
||||
},
|
||||
|
||||
invalidatePlaybackIntent(pauseContext = true) {
|
||||
this._intentGeneration += 1
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this.clearPauseLock(true)
|
||||
this.clearSeekState(!this._unloaded)
|
||||
if (
|
||||
pauseContext
|
||||
&& this.audioContext
|
||||
&& typeof this.audioContext.pause === 'function'
|
||||
) {
|
||||
this.audioContext.pause()
|
||||
}
|
||||
},
|
||||
|
||||
startPlaybackWithIntent(intentGeneration) {
|
||||
const context = this.audioContext
|
||||
if (
|
||||
!context
|
||||
|| this._unloaded
|
||||
|| intentGeneration !== this._intentGeneration
|
||||
|| !this._desiredPlayback
|
||||
) return false
|
||||
this._controlLocked = true
|
||||
this.setData({
|
||||
loading: true,
|
||||
playing: false,
|
||||
hasStarted: true,
|
||||
error: '',
|
||||
})
|
||||
context.play()
|
||||
return true
|
||||
},
|
||||
|
||||
bindAudioInterruptionHandlers() {
|
||||
if (this._audioInterruptionBeginHandler) return
|
||||
const generation = this._lifecycleGeneration
|
||||
this._audioInterruptionBeginHandler = () => {
|
||||
if (this._unloaded || this._lifecycleGeneration !== generation) return
|
||||
this.pauseWithoutAutoResume()
|
||||
}
|
||||
this._audioInterruptionEndHandler = () => {
|
||||
if (this._unloaded || this._lifecycleGeneration !== generation) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({ playing: false, loading: false })
|
||||
}
|
||||
if (typeof wx.onAudioInterruptionBegin === 'function') {
|
||||
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
|
||||
}
|
||||
if (typeof wx.onAudioInterruptionEnd === 'function') {
|
||||
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
|
||||
}
|
||||
},
|
||||
|
||||
unbindAudioInterruptionHandlers() {
|
||||
if (
|
||||
this._audioInterruptionBeginHandler
|
||||
&& typeof wx.offAudioInterruptionBegin === 'function'
|
||||
) {
|
||||
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
|
||||
}
|
||||
if (
|
||||
this._audioInterruptionEndHandler
|
||||
&& typeof wx.offAudioInterruptionEnd === 'function'
|
||||
) {
|
||||
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
|
||||
}
|
||||
this._audioInterruptionBeginHandler = null
|
||||
this._audioInterruptionEndHandler = null
|
||||
},
|
||||
|
||||
pauseWithoutAutoResume() {
|
||||
const context = this.audioContext
|
||||
this.invalidatePlaybackIntent(false)
|
||||
if (context && typeof context.pause === 'function') {
|
||||
this.beginPauseLock(context)
|
||||
try {
|
||||
context.pause()
|
||||
} catch (_) {
|
||||
this.clearPauseLock(true)
|
||||
}
|
||||
}
|
||||
if (!this._unloaded) {
|
||||
this.setData({ playing: false, loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
onHide() {
|
||||
this.pauseWithoutAutoResume()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (this._unloaded) return
|
||||
this.clearPauseLock(true)
|
||||
this.setData({ playing: false, loading: false, seekLocked: false })
|
||||
},
|
||||
|
||||
togglePlayback() {
|
||||
if (this.data.loading || this._controlLocked || this._unloaded) return
|
||||
if (!this.audioContext && this._page) {
|
||||
this.createAudioContext(this._page.audioSrc)
|
||||
}
|
||||
if (!this.audioContext) return
|
||||
if (this.data.playing || this._desiredPlayback) {
|
||||
this.pauseWithoutAutoResume()
|
||||
return
|
||||
}
|
||||
const intentGeneration = this.beginPlaybackIntent()
|
||||
this.startPlaybackWithIntent(intentGeneration)
|
||||
},
|
||||
|
||||
requestSeek(targetValue, options = {}) {
|
||||
const context = this.audioContext
|
||||
if (
|
||||
!context
|
||||
|| this._unloaded
|
||||
|| this.data.loading
|
||||
|| this._pendingSeek
|
||||
) return false
|
||||
const duration = Math.max(
|
||||
1,
|
||||
Number(context.duration) || Number(this.data.durationSeconds) || 1,
|
||||
)
|
||||
const target = clamp(targetValue, 0, duration)
|
||||
const actualBeforeSeek = Number(context.currentTime)
|
||||
const previousValue = clamp(
|
||||
Number.isFinite(actualBeforeSeek)
|
||||
? actualBeforeSeek
|
||||
: Number(this.data.sliderValue) || 0,
|
||||
0,
|
||||
duration,
|
||||
)
|
||||
const operationGeneration = this._seekOperationGeneration + 1
|
||||
this._seekOperationGeneration = operationGeneration
|
||||
const pending = {
|
||||
operationGeneration,
|
||||
contextGeneration: this._contextGeneration,
|
||||
intentGeneration: this._intentGeneration,
|
||||
context,
|
||||
target,
|
||||
previousValue,
|
||||
playAfterSeek: Boolean(options.playAfterSeek),
|
||||
}
|
||||
this._pendingSeek = pending
|
||||
this._scrubbing = false
|
||||
this.setProgress(target, duration)
|
||||
this.setData({ seekLocked: true, error: '' })
|
||||
this._seekTimer = setTimeout(() => {
|
||||
this.failSeek(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
}, SEEK_TIMEOUT_MS)
|
||||
try {
|
||||
context.seek(target)
|
||||
} catch (_) {
|
||||
this.failSeek(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
finishSeekFromEvent(context, contextGeneration) {
|
||||
const pending = this._pendingSeek
|
||||
if (!pending) return
|
||||
const actual = Number(context.currentTime)
|
||||
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
|
||||
this.finishSeek(
|
||||
pending.operationGeneration,
|
||||
contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
},
|
||||
|
||||
finishSeek(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingSeek
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
) return
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._pendingSeek = null
|
||||
this.setData({ seekLocked: false })
|
||||
if (
|
||||
pending.playAfterSeek
|
||||
&& this._desiredPlayback
|
||||
&& this._activePlayIntentGeneration === intentGeneration
|
||||
) {
|
||||
this.startPlaybackWithIntent(intentGeneration)
|
||||
}
|
||||
},
|
||||
|
||||
failSeek(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingSeek
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
) return false
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._pendingSeek = null
|
||||
this._scrubbing = false
|
||||
const actual = Number(context.currentTime)
|
||||
const restoredValue = Number.isFinite(actual)
|
||||
? actual
|
||||
: pending.previousValue
|
||||
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
|
||||
this.setProgress(restoredValue, this.data.durationSeconds)
|
||||
this.setData({
|
||||
playing: pending.playAfterSeek ? false : this.data.playing,
|
||||
loading: false,
|
||||
seekLocked: false,
|
||||
error: '进度没有调好,请再试一次。',
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
previewSeek(event) {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
this._scrubbing = true
|
||||
this.setProgress(eventValue(event), this.data.durationSeconds)
|
||||
},
|
||||
|
||||
commitSeek(event) {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
this._scrubbing = false
|
||||
this.requestSeek(eventValue(event))
|
||||
},
|
||||
|
||||
rewindTenSeconds() {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
const current = Number(this.audioContext.currentTime)
|
||||
const fallback = Number(this.data.sliderValue) || 0
|
||||
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
|
||||
},
|
||||
|
||||
replay() {
|
||||
if (
|
||||
!this.audioContext
|
||||
|| this.data.loading
|
||||
|| this._controlLocked
|
||||
|| this._pendingSeek
|
||||
|| this._unloaded
|
||||
) return
|
||||
const alreadyPlaying = this.data.playing && this._desiredPlayback
|
||||
const intentGeneration = alreadyPlaying
|
||||
? this._intentGeneration
|
||||
: this.beginPlaybackIntent()
|
||||
this.setData({ hasStarted: true, error: '' })
|
||||
this.requestSeek(0, {
|
||||
playAfterSeek: !alreadyPlaying,
|
||||
intentGeneration,
|
||||
})
|
||||
},
|
||||
|
||||
changePlaybackRate(event) {
|
||||
const requested = Number(
|
||||
event && event.currentTarget && event.currentTarget.dataset.rate,
|
||||
)
|
||||
if (
|
||||
!RATE_VALUES.includes(requested)
|
||||
|| !this.audioContext
|
||||
|| !this.data.rateControlsVisible
|
||||
) return
|
||||
const allowed = this.data.rateOptions.some((item) => item.value === requested)
|
||||
if (!allowed) return
|
||||
try {
|
||||
this.audioContext.playbackRate = requested
|
||||
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
|
||||
this.disableRateControls(this.audioContext)
|
||||
return
|
||||
}
|
||||
this.setData({ playbackRate: requested })
|
||||
} catch (_) {
|
||||
this.disableRateControls(this.audioContext)
|
||||
}
|
||||
},
|
||||
|
||||
goBack() {
|
||||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
|
||||
const returnToChapter = () => {
|
||||
if (typeof wx.reLaunch === 'function') {
|
||||
wx.reLaunch({
|
||||
url: '/package-game/pages/chapter/chapter?chapter=1',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
|
||||
wx.navigateBack({ delta: 1, fail: returnToChapter })
|
||||
return
|
||||
}
|
||||
returnToChapter()
|
||||
},
|
||||
|
||||
destroyAudioContext() {
|
||||
const context = this.audioContext
|
||||
if (!context) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this._contextGeneration += 1
|
||||
this.audioContext = null
|
||||
context.destroy()
|
||||
if (!this._unloaded) {
|
||||
this.setData({
|
||||
audioReady: false,
|
||||
playing: false,
|
||||
loading: false,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
playbackRate: 1,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._unloaded = true
|
||||
this._lifecycleGeneration += 1
|
||||
this._intentGeneration += 1
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this._controlLocked = true
|
||||
this.clearPauseLock(false)
|
||||
this.clearSeekState(false)
|
||||
this.unbindAudioInterruptionHandlers()
|
||||
const context = this.audioContext
|
||||
this._contextGeneration += 1
|
||||
this.audioContext = null
|
||||
if (context) context.destroy()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<view class="audio-leaf">
|
||||
<view class="audio-leaf-topbar">
|
||||
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画">‹ 返回连环画</button>
|
||||
<view class="leaf-heading">
|
||||
<text class="leaf-kicker">第01回 · 有声夹页</text>
|
||||
<text class="leaf-count">{{pageNumber}}/8</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="audio-leaf-spread">
|
||||
<view class="leaf-art-frame">
|
||||
<image class="leaf-art" src="{{imageSrc}}" mode="aspectFit"></image>
|
||||
<text class="leaf-stamp">桂香故事</text>
|
||||
</view>
|
||||
|
||||
<view class="leaf-copy">
|
||||
<text class="leaf-title">{{title}}</text>
|
||||
<text class="leaf-caption">{{caption}}</text>
|
||||
|
||||
<view class="leaf-seek-row">
|
||||
<slider
|
||||
class="leaf-slider"
|
||||
aria-label="播放进度,可左右拖动"
|
||||
min="0"
|
||||
max="{{durationSeconds}}"
|
||||
step="1"
|
||||
value="{{sliderValue}}"
|
||||
activeColor="#9b3328"
|
||||
backgroundColor="#d6c6a1"
|
||||
block-color="#8f271f"
|
||||
block-size="24"
|
||||
disabled="{{loading || !audioReady || seekLocked}}"
|
||||
bindchanging="previewSeek"
|
||||
bindchange="commitSeek"
|
||||
></slider>
|
||||
<text class="leaf-time">{{currentLabel}} / {{durationLabel}}</text>
|
||||
</view>
|
||||
|
||||
<view class="leaf-controls">
|
||||
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
|
||||
<button class="leaf-button primary" disabled="{{loading || !audioReady || seekLocked}}" bindtap="togglePlayback">
|
||||
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
|
||||
</button>
|
||||
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{rateControlsVisible}}" class="leaf-rate-row" aria-label="播放速度">
|
||||
<text class="leaf-rate-label">语速</text>
|
||||
<button
|
||||
wx:for="{{rateOptions}}"
|
||||
wx:key="value"
|
||||
class="leaf-rate-button {{playbackRate == item.value ? 'active' : ''}}"
|
||||
data-rate="{{item.value}}"
|
||||
disabled="{{loading || seekLocked}}"
|
||||
bindtap="changePlaybackRate"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
|
||||
<text wx:if="{{error}}" class="leaf-error">{{error}}</text>
|
||||
<button class="back-bottom" bindtap="goBack">看完,回到连环画</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,171 @@
|
||||
page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: #1c130f;
|
||||
color: #2f241d;
|
||||
}
|
||||
|
||||
button::after { border: 0; }
|
||||
|
||||
.audio-leaf {
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 320px;
|
||||
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: radial-gradient(circle at 48% 45%, #423024 0, #211611 70%);
|
||||
}
|
||||
|
||||
.audio-leaf-topbar {
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding-right: max(92px, env(safe-area-inset-right));
|
||||
color: #f5e6b7;
|
||||
}
|
||||
|
||||
.back-top {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 48px;
|
||||
border: 1px solid #9b7a51;
|
||||
border-radius: 10px;
|
||||
background: #38251b;
|
||||
color: #f7e8bd;
|
||||
font-size: 20px;
|
||||
line-height: 48px;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.leaf-heading { display: flex; align-items: baseline; gap: 12px; }
|
||||
.leaf-kicker { font-size: 20px; font-weight: 700; }
|
||||
.leaf-count { color: #d9bb78; font-size: 18px; }
|
||||
|
||||
.audio-leaf-spread {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
|
||||
border: 2px solid #b99762;
|
||||
background: #efe2bd;
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
|
||||
}
|
||||
|
||||
.leaf-art-frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #17100c;
|
||||
border-right: 4px solid #8d271f;
|
||||
}
|
||||
|
||||
.leaf-art { width: 100%; height: 100%; display: block; }
|
||||
.leaf-stamp {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: 16px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(86, 24, 20, .9);
|
||||
color: #f5e6bd;
|
||||
font-size: 17px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.leaf-copy {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 18px 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
|
||||
}
|
||||
|
||||
.leaf-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
|
||||
.leaf-caption { margin-top: 10px; font-size: 22px; font-weight: 600; line-height: 1.48; }
|
||||
.leaf-seek-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.leaf-slider { min-width: 0; width: 100%; margin: 0; }
|
||||
.leaf-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
|
||||
.leaf-controls {
|
||||
min-width: 0;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.leaf-button, .back-bottom {
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
border: 2px solid #7e6648;
|
||||
border-radius: 10px;
|
||||
background: #eadbb5;
|
||||
color: #37291f;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.leaf-button { width: 100%; max-width: 100%; }
|
||||
.leaf-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
|
||||
.leaf-button[disabled] { opacity: .72; }
|
||||
.leaf-rate-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.leaf-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
|
||||
.leaf-rate-button {
|
||||
box-sizing: border-box;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border: 2px solid #9d896a;
|
||||
border-radius: 9px;
|
||||
background: #f3e7c8;
|
||||
color: #4d3b2d;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
}
|
||||
.leaf-rate-button { width: 100%; max-width: 100%; }
|
||||
.leaf-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
|
||||
.leaf-rate-button[disabled] { opacity: .72; }
|
||||
.leaf-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
|
||||
.back-bottom { margin-top: 8px; width: 100%; }
|
||||
|
||||
@media (max-height: 430px) {
|
||||
.audio-leaf { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
|
||||
.audio-leaf-topbar { height: 48px; flex-basis: 48px; }
|
||||
.leaf-copy { padding: 8px 12px; overflow-y: auto; }
|
||||
.leaf-title { font-size: 23px; }
|
||||
.leaf-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
|
||||
.leaf-seek-row { min-height: 48px; }
|
||||
.leaf-controls, .leaf-rate-row { margin-top: 3px; gap: 5px; }
|
||||
.leaf-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
|
||||
.leaf-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
|
||||
.leaf-rate-label { font-size: 16px; }
|
||||
.back-bottom { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 发布环境可覆盖这些值。
|
||||
*
|
||||
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
|
||||
* 使用包内种子图或返回文字回退,不会发起网络请求。
|
||||
*/
|
||||
module.exports = {
|
||||
cdnBaseUrl: '',
|
||||
imageCacheBudgetBytes: 28 * 1024 * 1024,
|
||||
audioCacheBudgetBytes: 32 * 1024 * 1024,
|
||||
audioCacheMaxEntries: 32,
|
||||
prefetchAhead: 2,
|
||||
downloadConcurrency: 2,
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* 发布固定资产清单 v1
|
||||
*
|
||||
* 约束:
|
||||
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
|
||||
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
|
||||
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
|
||||
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
|
||||
*/
|
||||
|
||||
const RELEASE_ASSET_MANIFEST_VERSION = 1
|
||||
|
||||
const releaseAssets = {
|
||||
'comic.s01.c01.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
|
||||
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
|
||||
},
|
||||
'comic.s01.c01.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
|
||||
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
|
||||
},
|
||||
'comic.s01.c01.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
|
||||
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
|
||||
},
|
||||
'comic.s01.c01.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
|
||||
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
|
||||
},
|
||||
'comic.s01.c01.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
|
||||
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
|
||||
},
|
||||
'comic.s01.c01.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
|
||||
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
|
||||
},
|
||||
'comic.s01.c01.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
|
||||
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
|
||||
},
|
||||
'comic.s01.c01.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
|
||||
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
|
||||
},
|
||||
'comic.s01.c02.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
|
||||
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
|
||||
},
|
||||
'comic.s01.c02.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
|
||||
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
|
||||
},
|
||||
'comic.s01.c02.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
|
||||
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
|
||||
},
|
||||
'comic.s01.c02.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
|
||||
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
|
||||
},
|
||||
'comic.s01.c02.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
|
||||
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
|
||||
},
|
||||
'comic.s01.c02.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
|
||||
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
|
||||
},
|
||||
'comic.s01.c02.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
|
||||
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
|
||||
},
|
||||
'comic.s01.c02.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
|
||||
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
|
||||
},
|
||||
'comic.s01.c03.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
|
||||
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
|
||||
},
|
||||
'comic.s01.c03.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
|
||||
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
|
||||
},
|
||||
'comic.s01.c03.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
|
||||
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
|
||||
},
|
||||
'comic.s01.c03.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
|
||||
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
|
||||
},
|
||||
'comic.s01.c03.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
|
||||
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
|
||||
},
|
||||
'comic.s01.c03.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
|
||||
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
|
||||
},
|
||||
'comic.s01.c03.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
|
||||
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
|
||||
},
|
||||
'comic.s01.c03.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
|
||||
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
|
||||
},
|
||||
'comic.s01.c04.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
|
||||
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
|
||||
},
|
||||
'comic.s01.c04.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
|
||||
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
|
||||
},
|
||||
'comic.s01.c04.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
|
||||
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
|
||||
},
|
||||
'comic.s01.c04.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
|
||||
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
|
||||
},
|
||||
'comic.s01.c04.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
|
||||
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
|
||||
},
|
||||
'comic.s01.c04.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
|
||||
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
|
||||
},
|
||||
'comic.s01.c04.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
|
||||
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
|
||||
},
|
||||
'comic.s01.c04.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
|
||||
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
|
||||
},
|
||||
'comic.s01.c05.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
|
||||
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
|
||||
},
|
||||
'comic.s01.c05.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
|
||||
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
|
||||
},
|
||||
'comic.s01.c05.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
|
||||
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
|
||||
},
|
||||
'comic.s01.c05.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
|
||||
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
|
||||
},
|
||||
'comic.s01.c05.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
|
||||
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
|
||||
},
|
||||
'comic.s01.c05.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
|
||||
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
|
||||
},
|
||||
'comic.s01.c05.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
|
||||
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
|
||||
},
|
||||
'comic.s01.c05.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
|
||||
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
|
||||
},
|
||||
'comic.s01.c06.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
|
||||
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
|
||||
},
|
||||
'comic.s01.c06.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
|
||||
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
|
||||
},
|
||||
'comic.s01.c06.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
|
||||
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
|
||||
},
|
||||
'comic.s01.c06.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
|
||||
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
|
||||
},
|
||||
'comic.s01.c06.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
|
||||
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
|
||||
},
|
||||
'comic.s01.c06.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
|
||||
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
|
||||
},
|
||||
'comic.s01.c06.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
|
||||
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
|
||||
},
|
||||
'comic.s01.c06.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
|
||||
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
|
||||
},
|
||||
'comic.s01.c07.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
|
||||
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
|
||||
},
|
||||
'comic.s01.c07.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
|
||||
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
|
||||
},
|
||||
'comic.s01.c07.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
|
||||
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
|
||||
},
|
||||
'comic.s01.c07.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
|
||||
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
|
||||
},
|
||||
'comic.s01.c07.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
|
||||
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
|
||||
},
|
||||
'comic.s01.c07.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
|
||||
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
|
||||
},
|
||||
'comic.s01.c07.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
|
||||
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
|
||||
},
|
||||
'comic.s01.c07.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
|
||||
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
|
||||
},
|
||||
'comic.s01.c08.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
|
||||
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
|
||||
},
|
||||
'comic.s01.c08.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
|
||||
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
|
||||
},
|
||||
'comic.s01.c08.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
|
||||
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
|
||||
},
|
||||
'comic.s01.c08.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
|
||||
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
|
||||
},
|
||||
'comic.s01.c08.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
|
||||
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
|
||||
},
|
||||
'comic.s01.c08.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
|
||||
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
|
||||
},
|
||||
'comic.s01.c08.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
|
||||
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
|
||||
},
|
||||
'comic.s01.c08.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
|
||||
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
|
||||
},
|
||||
'comic.s01.c09.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
|
||||
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
|
||||
},
|
||||
'comic.s01.c09.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
|
||||
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
|
||||
},
|
||||
'comic.s01.c09.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
|
||||
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
|
||||
},
|
||||
'comic.s01.c09.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
|
||||
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
|
||||
},
|
||||
'comic.s01.c09.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
|
||||
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
|
||||
},
|
||||
'comic.s01.c09.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
|
||||
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
|
||||
},
|
||||
'comic.s01.c09.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
|
||||
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
|
||||
},
|
||||
'comic.s01.c09.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
|
||||
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
|
||||
},
|
||||
'comic.s01.c10.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
|
||||
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
|
||||
},
|
||||
'comic.s01.c10.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
|
||||
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
|
||||
},
|
||||
'comic.s01.c10.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
|
||||
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
|
||||
},
|
||||
'comic.s01.c10.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
|
||||
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
|
||||
},
|
||||
'comic.s01.c10.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
|
||||
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
|
||||
},
|
||||
'comic.s01.c10.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
|
||||
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
|
||||
},
|
||||
'comic.s01.c10.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
|
||||
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
|
||||
},
|
||||
'comic.s01.c10.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
|
||||
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
|
||||
},
|
||||
'comic.s01.c11.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
|
||||
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
|
||||
},
|
||||
'comic.s01.c11.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
|
||||
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
|
||||
},
|
||||
'comic.s01.c11.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
|
||||
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
|
||||
},
|
||||
'comic.s01.c11.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
|
||||
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
|
||||
},
|
||||
'comic.s01.c11.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
|
||||
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
|
||||
},
|
||||
'comic.s01.c11.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
|
||||
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
|
||||
},
|
||||
'comic.s01.c11.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
|
||||
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
|
||||
},
|
||||
'comic.s01.c11.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
|
||||
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
|
||||
},
|
||||
'comic.s01.c12.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
|
||||
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
|
||||
},
|
||||
'comic.s01.c12.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
|
||||
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
|
||||
},
|
||||
'comic.s01.c12.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
|
||||
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
|
||||
},
|
||||
'comic.s01.c12.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
|
||||
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
|
||||
},
|
||||
'comic.s01.c12.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
|
||||
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
|
||||
},
|
||||
'comic.s01.c12.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
|
||||
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
|
||||
},
|
||||
'comic.s01.c12.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
|
||||
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
|
||||
},
|
||||
'comic.s01.c12.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
|
||||
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
|
||||
},
|
||||
'comic.s01.c13.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
|
||||
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
|
||||
},
|
||||
'comic.s01.c13.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
|
||||
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
|
||||
},
|
||||
'comic.s01.c13.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
|
||||
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
|
||||
},
|
||||
'comic.s01.c13.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
|
||||
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
|
||||
},
|
||||
'comic.s01.c13.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
|
||||
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
|
||||
},
|
||||
'comic.s01.c13.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
|
||||
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
|
||||
},
|
||||
'comic.s01.c13.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
|
||||
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
|
||||
},
|
||||
'comic.s01.c13.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
|
||||
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
|
||||
},
|
||||
'comic.s01.c14.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
|
||||
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
|
||||
},
|
||||
'comic.s01.c14.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
|
||||
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
|
||||
},
|
||||
'comic.s01.c14.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
|
||||
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
|
||||
},
|
||||
'comic.s01.c14.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
|
||||
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
|
||||
},
|
||||
'comic.s01.c14.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
|
||||
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
|
||||
},
|
||||
'comic.s01.c14.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
|
||||
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
|
||||
},
|
||||
'comic.s01.c14.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
|
||||
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
|
||||
},
|
||||
'comic.s01.c14.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
|
||||
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
|
||||
},
|
||||
'comic.s01.c15.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
|
||||
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
|
||||
},
|
||||
'comic.s01.c15.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
|
||||
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
|
||||
},
|
||||
'comic.s01.c15.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
|
||||
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
|
||||
},
|
||||
'comic.s01.c15.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
|
||||
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
|
||||
},
|
||||
'comic.s01.c15.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
|
||||
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
|
||||
},
|
||||
'comic.s01.c15.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
|
||||
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
|
||||
},
|
||||
'comic.s01.c15.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
|
||||
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
|
||||
},
|
||||
'comic.s01.c15.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
|
||||
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
|
||||
},
|
||||
'character.female-cook': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/female-cook.jpg',
|
||||
remotePath: 'characters/female-cook.c56420b2c632.jpg',
|
||||
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
|
||||
},
|
||||
'character.lele': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lele.jpg',
|
||||
remotePath: 'characters/lele.807f5232b1e8.jpg',
|
||||
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
|
||||
},
|
||||
'character.lin-xiulan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lin-xiulan.jpg',
|
||||
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
|
||||
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
|
||||
},
|
||||
'character.qin-xiaoman': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-xiaoman.jpg',
|
||||
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
|
||||
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
|
||||
},
|
||||
'character.qin-zhicheng': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-zhicheng.jpg',
|
||||
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
|
||||
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
|
||||
},
|
||||
'character.tang-mingyuan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-mingyuan.jpg',
|
||||
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
|
||||
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
|
||||
},
|
||||
'character.tang-shouan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-shouan.jpg',
|
||||
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
|
||||
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
|
||||
},
|
||||
'character.xiaozhen': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/xiaozhen.jpg',
|
||||
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
|
||||
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
|
||||
},
|
||||
'character.zhao-jianguo': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/zhao-jianguo.jpg',
|
||||
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
|
||||
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
|
||||
},
|
||||
'scene.gui-xiang.1978': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
|
||||
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
|
||||
},
|
||||
'scene.gui-xiang.1995': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
|
||||
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
|
||||
},
|
||||
'scene.gui-xiang.1998': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
|
||||
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
|
||||
},
|
||||
'scene.gui-xiang.2001': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
|
||||
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
|
||||
},
|
||||
'scene.gui-xiang.2003': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
|
||||
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
|
||||
},
|
||||
'scene.gui-xiang.2008': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
|
||||
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
|
||||
},
|
||||
'scene.gui-xiang.2026': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
|
||||
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
|
||||
},
|
||||
'audio.cast-audition.v1': {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'audition',
|
||||
localSeed: '',
|
||||
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
|
||||
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
|
||||
// The reader no longer maps them as page narration; C01 uses full-page tracks
|
||||
// through the two package-local audio player pages instead.
|
||||
for (const cueId of [
|
||||
'S01-C01-MS007','S01-C01-MS010',
|
||||
]) {
|
||||
const file = `${cueId}.mp3`
|
||||
const sha256 = {
|
||||
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
|
||||
}[cueId]
|
||||
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
|
||||
}
|
||||
|
||||
const audioC02Sha256 = {
|
||||
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
|
||||
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
|
||||
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
|
||||
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
|
||||
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
|
||||
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
|
||||
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
|
||||
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
|
||||
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
|
||||
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
|
||||
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
|
||||
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
|
||||
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
|
||||
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
|
||||
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
|
||||
}
|
||||
|
||||
const audioC03Sha256 = {
|
||||
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
|
||||
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
|
||||
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
|
||||
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
|
||||
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
|
||||
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
|
||||
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
|
||||
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
|
||||
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
|
||||
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
|
||||
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
|
||||
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
|
||||
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
|
||||
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
|
||||
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
|
||||
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
|
||||
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
|
||||
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
|
||||
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
|
||||
}
|
||||
|
||||
// C02/C03 cue files are retained only as rollback/source material. A single
|
||||
// cue is not a complete page track, so these assets must not pass the runtime
|
||||
// audio gate or appear as “试听本页”.
|
||||
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
|
||||
for (const [cueId, sha256] of Object.entries(audioHashes)) {
|
||||
const file = `${cueId}.mp3`
|
||||
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'reserved',
|
||||
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
|
||||
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RELEASE_ASSET_MANIFEST_VERSION,
|
||||
releaseAssets,
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* C02-C15 remote full-page audio manifest v1.
|
||||
*
|
||||
* Import contract for a future reviewed batch:
|
||||
*
|
||||
* 'S01-C09-P05': Object.freeze({
|
||||
* pageId: 'S01-C09-P05',
|
||||
* chapterNumber: 9,
|
||||
* pageNumber: 5,
|
||||
* assetId: 'audio.page.s01.c09.p05',
|
||||
* durationSeconds: 42.1,
|
||||
* reviewStatus: 'approved',
|
||||
* })
|
||||
*
|
||||
* The matching releaseAssetManifest entry must independently be an approved,
|
||||
* remote-only, immutable hash-named audio asset. Automated QA, pending human
|
||||
* listening, and missing CDN records must never be relabelled as approved.
|
||||
*/
|
||||
|
||||
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
|
||||
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
// Deliberately empty until full-page tracks pass the required human reviews
|
||||
// and their immutable CDN objects are available. This is a release gate, not
|
||||
// a placeholder that the UI may treat as playable.
|
||||
const remotePageAudioPages = Object.freeze({})
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function remotePageAudioAssetId(pageId) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return ''
|
||||
return `audio.page.s01.c${match[1]}.p${match[2]}`
|
||||
}
|
||||
|
||||
function getApprovedRemotePageAudio(
|
||||
pageId,
|
||||
releaseAssets = {},
|
||||
pageManifest = remotePageAudioPages,
|
||||
) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return null
|
||||
|
||||
const entry = pageManifest && pageManifest[stablePageId]
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const chapterNumber = Number(match[1])
|
||||
const pageNumber = Number(match[2])
|
||||
const assetId = remotePageAudioAssetId(stablePageId)
|
||||
const durationSeconds = Number(entry.durationSeconds)
|
||||
if (
|
||||
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| entry.pageId !== stablePageId
|
||||
|| Number(entry.chapterNumber) !== chapterNumber
|
||||
|| Number(entry.pageNumber) !== pageNumber
|
||||
|| entry.assetId !== assetId
|
||||
|| !Number.isFinite(durationSeconds)
|
||||
|| durationSeconds <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const asset = releaseAssets && releaseAssets[assetId]
|
||||
const sha256 = asset && typeof asset.sha256 === 'string'
|
||||
? asset.sha256
|
||||
: ''
|
||||
const remotePath = asset && asset.remotePath
|
||||
const expectedRemotePath = (
|
||||
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
|
||||
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
|
||||
)
|
||||
if (
|
||||
!asset
|
||||
|| asset.kind !== 'audio'
|
||||
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| asset.localSeed !== ''
|
||||
|| !SHA256_PATTERN.test(sha256)
|
||||
|| typeof remotePath !== 'string'
|
||||
|| remotePath !== expectedRemotePath
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...entry, assetId }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REMOTE_PAGE_AUDIO_ID_PATTERN,
|
||||
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
|
||||
getApprovedRemotePageAudio,
|
||||
remotePageAudioAssetId,
|
||||
remotePageAudioPages,
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
const {
|
||||
getApprovedRemotePageAudio,
|
||||
} = require('../../data/remotePageAudioManifest')
|
||||
const {
|
||||
releaseAssets,
|
||||
} = require('../../data/releaseAssetManifest')
|
||||
const assetReleaseConfig = require('../../data/assetReleaseConfig')
|
||||
const {
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
} = require('../../utils/assetManager')
|
||||
const {
|
||||
chapterRoute,
|
||||
normalizeChapterNumber,
|
||||
} = require('../../utils/chapterRoute')
|
||||
|
||||
const REMOTE_PAGE_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
|
||||
const SEEK_TIMEOUT_MS = 1600
|
||||
const PAUSE_LOCK_TIMEOUT_MS = 1200
|
||||
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
|
||||
const RATE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 0.8, label: '0.8倍' }),
|
||||
Object.freeze({ value: 1, label: '1.0倍' }),
|
||||
Object.freeze({ value: 1.2, label: '1.2倍' }),
|
||||
])
|
||||
const FALLBACK_RATE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 1, label: '1.0倍' }),
|
||||
])
|
||||
|
||||
function formatTime(value) {
|
||||
const seconds = Math.max(0, Math.floor(Number(value) || 0))
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
|
||||
}
|
||||
|
||||
function eventValue(event) {
|
||||
return Number(event && event.detail && event.detail.value) || 0
|
||||
}
|
||||
|
||||
function resolveErrorMessage(reason) {
|
||||
if (reason === 'remote-disabled') {
|
||||
return '声音下载地址还没配置好,请返回连环画,稍后再试。'
|
||||
}
|
||||
if (reason === 'remote-failed') {
|
||||
return '网络不太稳,声音没有下载下来。请稍后再试。'
|
||||
}
|
||||
if (reason === 'remote-integrity-failed') {
|
||||
return '声音文件下载不完整,安全校验没有通过。请稍后重试。'
|
||||
}
|
||||
if (reason === 'audio-unapproved') {
|
||||
return '这一页的声音还在审核,暂时不能播放。'
|
||||
}
|
||||
return '这一页的声音暂时没有打开,请返回连环画,稍后再试。'
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageId: '',
|
||||
chapterNumber: 2,
|
||||
pageNumber: 1,
|
||||
chapterLabel: '第02回',
|
||||
pageLabel: '第1页',
|
||||
playing: false,
|
||||
loading: false,
|
||||
audioReady: false,
|
||||
hasStarted: false,
|
||||
seekLocked: false,
|
||||
retryAvailable: false,
|
||||
error: '',
|
||||
currentLabel: '0:00',
|
||||
durationLabel: '0:00',
|
||||
durationSeconds: 1,
|
||||
sliderValue: 0,
|
||||
progressStyle: 'width:0%',
|
||||
playbackRate: 1,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
},
|
||||
|
||||
onLoad(options = {}) {
|
||||
this._unloaded = false
|
||||
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
|
||||
this._contextGeneration = 0
|
||||
this._intentGeneration = 0
|
||||
this._seekOperationGeneration = 0
|
||||
this._controlOperationGeneration = 0
|
||||
this._prepareGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._preparePlayIntentGeneration = 0
|
||||
this._controlLocked = false
|
||||
this._scrubbing = false
|
||||
this._pendingSeek = null
|
||||
this._seekTimer = null
|
||||
this._pendingPauseLock = null
|
||||
this._pauseLockTimer = null
|
||||
this._preparePromise = null
|
||||
this._pageAudio = null
|
||||
this.bindAudioInterruptionHandlers()
|
||||
const pageId = String(options.pageId || '').trim()
|
||||
const match = REMOTE_PAGE_ID_PATTERN.exec(pageId)
|
||||
const chapterNumber = normalizeChapterNumber(options.chapterNumber)
|
||||
const pageChapterNumber = match ? Number(match[1]) : 0
|
||||
const pageNumber = match ? Number(match[2]) : 0
|
||||
this.setData({
|
||||
pageId,
|
||||
chapterNumber,
|
||||
pageNumber: pageNumber || 1,
|
||||
chapterLabel: `第${String(chapterNumber).padStart(2, '0')}回`,
|
||||
pageLabel: `第${pageNumber || 1}页`,
|
||||
})
|
||||
|
||||
if (
|
||||
!match
|
||||
|| chapterNumber < 2
|
||||
|| pageChapterNumber !== chapterNumber
|
||||
) {
|
||||
this.setData({ error: '页面地址不对,请返回连环画重新打开。' })
|
||||
return
|
||||
}
|
||||
|
||||
const pageAudio = getApprovedRemotePageAudio(pageId, releaseAssets)
|
||||
if (!pageAudio) {
|
||||
this.setData({ error: '这一页的声音还在审核,暂时不能播放。' })
|
||||
return
|
||||
}
|
||||
this._pageAudio = pageAudio
|
||||
const durationSeconds = Math.max(1, Number(pageAudio.durationSeconds) || 1)
|
||||
this.setData({
|
||||
durationLabel: formatTime(durationSeconds),
|
||||
durationSeconds,
|
||||
})
|
||||
},
|
||||
|
||||
onReady() {
|
||||
// Opening the leaf is text-only. Downloading starts after an explicit tap.
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
|
||||
isCurrentContext(context, generation) {
|
||||
return !this._unloaded
|
||||
&& this.audioContext === context
|
||||
&& this._contextGeneration === generation
|
||||
},
|
||||
|
||||
setProgress(value, durationValue) {
|
||||
const duration = Math.max(
|
||||
1,
|
||||
Number(durationValue) || Number(this.data.durationSeconds) || 1,
|
||||
)
|
||||
const current = clamp(value, 0, duration)
|
||||
const percent = Math.min(100, current / duration * 100)
|
||||
this.setData({
|
||||
currentLabel: formatTime(current),
|
||||
durationLabel: formatTime(duration),
|
||||
durationSeconds: duration,
|
||||
sliderValue: current,
|
||||
progressStyle: `width:${percent}%`,
|
||||
})
|
||||
},
|
||||
|
||||
disableRateControls(context) {
|
||||
try {
|
||||
if (context && typeof context.playbackRate === 'number') {
|
||||
context.playbackRate = 1
|
||||
}
|
||||
} catch (_) {
|
||||
// A runtime that rejects playbackRate remains safely at normal speed.
|
||||
}
|
||||
this.setData({
|
||||
playbackRate: 1,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
})
|
||||
},
|
||||
|
||||
configureRateControls(context, generation) {
|
||||
try {
|
||||
if (typeof context.playbackRate !== 'number') {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
for (const rate of RATE_VALUES) {
|
||||
context.playbackRate = rate
|
||||
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
}
|
||||
context.playbackRate = 1
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
|
||||
) {
|
||||
this.disableRateControls(context)
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
playbackRate: 1,
|
||||
rateOptions: RATE_OPTIONS,
|
||||
rateControlsVisible: true,
|
||||
})
|
||||
} catch (_) {
|
||||
this.disableRateControls(context)
|
||||
}
|
||||
},
|
||||
|
||||
getAssetManager() {
|
||||
if (!this._assetManager) {
|
||||
this._assetManager = createAssetManager({
|
||||
assetPlatform: createAssetPlatformFacade(),
|
||||
manifest: releaseAssets,
|
||||
...assetReleaseConfig,
|
||||
})
|
||||
}
|
||||
return this._assetManager
|
||||
},
|
||||
|
||||
prepareAudio(intentGeneration) {
|
||||
if (
|
||||
!this._pageAudio
|
||||
|| this._unloaded
|
||||
|| intentGeneration !== this._intentGeneration
|
||||
|| !this._desiredPlayback
|
||||
) return Promise.resolve(null)
|
||||
this._preparePlayIntentGeneration = intentGeneration
|
||||
if (this._preparePromise) return this._preparePromise
|
||||
const prepareGeneration = this._prepareGeneration + 1
|
||||
this._prepareGeneration = prepareGeneration
|
||||
this._controlLocked = true
|
||||
this.setData({
|
||||
loading: true,
|
||||
playing: false,
|
||||
hasStarted: true,
|
||||
retryAvailable: false,
|
||||
error: '',
|
||||
})
|
||||
const assetId = this._pageAudio.assetId
|
||||
const promise = this.getAssetManager().resolve(assetId)
|
||||
.then((result) => {
|
||||
if (
|
||||
this._unloaded
|
||||
|| this._prepareGeneration !== prepareGeneration
|
||||
) return null
|
||||
const activeIntent = this._preparePlayIntentGeneration
|
||||
if (!result || !result.available || !result.uri) {
|
||||
if (
|
||||
activeIntent === this._intentGeneration
|
||||
&& this._desiredPlayback
|
||||
) {
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({
|
||||
playing: false,
|
||||
loading: false,
|
||||
retryAvailable: true,
|
||||
error: resolveErrorMessage(result && result.reason),
|
||||
})
|
||||
}
|
||||
return result || null
|
||||
}
|
||||
if (
|
||||
!activeIntent
|
||||
|| activeIntent !== this._intentGeneration
|
||||
|| !this._desiredPlayback
|
||||
) return result
|
||||
this.createAudioContext(result.uri)
|
||||
if (
|
||||
activeIntent === this._intentGeneration
|
||||
&& this._desiredPlayback
|
||||
) {
|
||||
this.startPlaybackWithIntent(activeIntent)
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch(() => {
|
||||
if (
|
||||
!this._unloaded
|
||||
&& this._prepareGeneration === prepareGeneration
|
||||
&& this._preparePlayIntentGeneration === this._intentGeneration
|
||||
&& this._desiredPlayback
|
||||
) {
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({
|
||||
playing: false,
|
||||
loading: false,
|
||||
retryAvailable: true,
|
||||
error: resolveErrorMessage('remote-failed'),
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
if (this._preparePromise === promise) this._preparePromise = null
|
||||
})
|
||||
this._preparePromise = promise
|
||||
return promise
|
||||
},
|
||||
|
||||
createAudioContext(src) {
|
||||
if (this._unloaded) return null
|
||||
if (this.audioContext) this.destroyAudioContext()
|
||||
const context = wx.createInnerAudioContext()
|
||||
const generation = this._contextGeneration + 1
|
||||
this._contextGeneration = generation
|
||||
this.audioContext = context
|
||||
context.autoplay = false
|
||||
context.src = src
|
||||
this.setData({
|
||||
audioReady: true,
|
||||
loading: false,
|
||||
playing: false,
|
||||
retryAvailable: false,
|
||||
error: '',
|
||||
})
|
||||
this.configureRateControls(context, generation)
|
||||
|
||||
context.onCanplay(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.setData({ audioReady: true, error: '' })
|
||||
})
|
||||
context.onPlay(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
if (
|
||||
!this._desiredPlayback
|
||||
|| this._activePlayIntentGeneration !== this._intentGeneration
|
||||
) {
|
||||
context.pause()
|
||||
this._controlLocked = false
|
||||
this.setData({ playing: false, loading: false })
|
||||
return
|
||||
}
|
||||
this._controlLocked = false
|
||||
this.setData({
|
||||
playing: true,
|
||||
loading: false,
|
||||
hasStarted: true,
|
||||
retryAvailable: false,
|
||||
error: '',
|
||||
})
|
||||
})
|
||||
context.onPause(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
if (this._desiredPlayback) return
|
||||
this.releasePauseLockFromEvent(context, generation)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onStop(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onWaiting(() => {
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| !this._desiredPlayback
|
||||
) return
|
||||
this.setData({ playing: false, loading: true })
|
||||
})
|
||||
context.onEnded(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
|
||||
this.setData({ playing: false, loading: false })
|
||||
})
|
||||
context.onTimeUpdate(() => {
|
||||
if (
|
||||
!this.isCurrentContext(context, generation)
|
||||
|| this._scrubbing
|
||||
|| this._pendingSeek
|
||||
) return
|
||||
const duration = Number(context.duration) || this.data.durationSeconds
|
||||
const current = Number(context.currentTime) || 0
|
||||
this.setProgress(current, duration)
|
||||
})
|
||||
if (typeof context.onSeeked === 'function') {
|
||||
context.onSeeked(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.finishSeekFromEvent(context, generation)
|
||||
})
|
||||
}
|
||||
context.onError(() => {
|
||||
if (!this.isCurrentContext(context, generation)) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({
|
||||
playing: false,
|
||||
loading: false,
|
||||
retryAvailable: true,
|
||||
error: '声音播放失败了。您可以重试,或返回连环画继续看。',
|
||||
})
|
||||
})
|
||||
this.bindAudioInterruptionHandlers()
|
||||
return context
|
||||
},
|
||||
|
||||
beginPlaybackIntent() {
|
||||
this._intentGeneration += 1
|
||||
this._desiredPlayback = true
|
||||
this._activePlayIntentGeneration = this._intentGeneration
|
||||
return this._intentGeneration
|
||||
},
|
||||
|
||||
clearSeekState(updateData = true) {
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._seekOperationGeneration += 1
|
||||
this._pendingSeek = null
|
||||
this._scrubbing = false
|
||||
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
|
||||
},
|
||||
|
||||
clearPauseLock(unlock = true) {
|
||||
if (this._pauseLockTimer !== null) {
|
||||
clearTimeout(this._pauseLockTimer)
|
||||
this._pauseLockTimer = null
|
||||
}
|
||||
this._controlOperationGeneration += 1
|
||||
this._pendingPauseLock = null
|
||||
if (unlock) this._controlLocked = false
|
||||
},
|
||||
|
||||
beginPauseLock(context) {
|
||||
this.clearPauseLock(true)
|
||||
const operationGeneration = this._controlOperationGeneration + 1
|
||||
this._controlOperationGeneration = operationGeneration
|
||||
const pending = {
|
||||
operationGeneration,
|
||||
contextGeneration: this._contextGeneration,
|
||||
intentGeneration: this._intentGeneration,
|
||||
lifecycleGeneration: this._lifecycleGeneration,
|
||||
context,
|
||||
}
|
||||
this._pendingPauseLock = pending
|
||||
this._controlLocked = true
|
||||
this._pauseLockTimer = setTimeout(() => {
|
||||
this.releasePauseLock(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
pending.lifecycleGeneration,
|
||||
context,
|
||||
)
|
||||
}, PAUSE_LOCK_TIMEOUT_MS)
|
||||
},
|
||||
|
||||
releasePauseLock(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
lifecycleGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingPauseLock
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.lifecycleGeneration !== lifecycleGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
|| this._lifecycleGeneration !== lifecycleGeneration
|
||||
|| this._desiredPlayback
|
||||
) return false
|
||||
if (this._pauseLockTimer !== null) {
|
||||
clearTimeout(this._pauseLockTimer)
|
||||
this._pauseLockTimer = null
|
||||
}
|
||||
this._pendingPauseLock = null
|
||||
this._controlLocked = false
|
||||
return true
|
||||
},
|
||||
|
||||
releasePauseLockFromEvent(context, contextGeneration) {
|
||||
const pending = this._pendingPauseLock
|
||||
if (!pending) {
|
||||
if (this.isCurrentContext(context, contextGeneration)) {
|
||||
this._controlLocked = false
|
||||
}
|
||||
return
|
||||
}
|
||||
this.releasePauseLock(
|
||||
pending.operationGeneration,
|
||||
contextGeneration,
|
||||
pending.intentGeneration,
|
||||
pending.lifecycleGeneration,
|
||||
context,
|
||||
)
|
||||
},
|
||||
|
||||
invalidatePlaybackIntent(pauseContext = true) {
|
||||
this._intentGeneration += 1
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._preparePlayIntentGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this.clearPauseLock(true)
|
||||
this.clearSeekState(!this._unloaded)
|
||||
if (
|
||||
pauseContext
|
||||
&& this.audioContext
|
||||
&& typeof this.audioContext.pause === 'function'
|
||||
) {
|
||||
this.audioContext.pause()
|
||||
}
|
||||
},
|
||||
|
||||
startPlaybackWithIntent(intentGeneration) {
|
||||
const context = this.audioContext
|
||||
if (
|
||||
!context
|
||||
|| this._unloaded
|
||||
|| intentGeneration !== this._intentGeneration
|
||||
|| !this._desiredPlayback
|
||||
) return false
|
||||
this._controlLocked = true
|
||||
this.setData({
|
||||
loading: true,
|
||||
playing: false,
|
||||
hasStarted: true,
|
||||
retryAvailable: false,
|
||||
error: '',
|
||||
})
|
||||
context.play()
|
||||
return true
|
||||
},
|
||||
|
||||
bindAudioInterruptionHandlers() {
|
||||
if (this._audioInterruptionBeginHandler) return
|
||||
const generation = this._lifecycleGeneration
|
||||
this._audioInterruptionBeginHandler = () => {
|
||||
if (this._unloaded || this._lifecycleGeneration !== generation) return
|
||||
this.pauseWithoutAutoResume()
|
||||
}
|
||||
this._audioInterruptionEndHandler = () => {
|
||||
if (this._unloaded || this._lifecycleGeneration !== generation) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this.setData({ playing: false, loading: false })
|
||||
}
|
||||
if (typeof wx.onAudioInterruptionBegin === 'function') {
|
||||
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
|
||||
}
|
||||
if (typeof wx.onAudioInterruptionEnd === 'function') {
|
||||
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
|
||||
}
|
||||
},
|
||||
|
||||
unbindAudioInterruptionHandlers() {
|
||||
if (
|
||||
this._audioInterruptionBeginHandler
|
||||
&& typeof wx.offAudioInterruptionBegin === 'function'
|
||||
) {
|
||||
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
|
||||
}
|
||||
if (
|
||||
this._audioInterruptionEndHandler
|
||||
&& typeof wx.offAudioInterruptionEnd === 'function'
|
||||
) {
|
||||
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
|
||||
}
|
||||
this._audioInterruptionBeginHandler = null
|
||||
this._audioInterruptionEndHandler = null
|
||||
},
|
||||
|
||||
pauseWithoutAutoResume() {
|
||||
const context = this.audioContext
|
||||
this.invalidatePlaybackIntent(false)
|
||||
if (context && typeof context.pause === 'function') {
|
||||
this.beginPauseLock(context)
|
||||
try {
|
||||
context.pause()
|
||||
} catch (_) {
|
||||
this.clearPauseLock(true)
|
||||
}
|
||||
}
|
||||
if (!this._unloaded) {
|
||||
this.setData({ playing: false, loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
onHide() {
|
||||
this.pauseWithoutAutoResume()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (this._unloaded) return
|
||||
this.clearPauseLock(true)
|
||||
this.setData({ playing: false, loading: false, seekLocked: false })
|
||||
},
|
||||
|
||||
togglePlayback() {
|
||||
if (this.data.loading || this._controlLocked || this._unloaded) return
|
||||
if (!this.audioContext) {
|
||||
const intentGeneration = this.beginPlaybackIntent()
|
||||
return this.prepareAudio(intentGeneration)
|
||||
}
|
||||
if (this.data.playing || this._desiredPlayback) {
|
||||
this.pauseWithoutAutoResume()
|
||||
return
|
||||
}
|
||||
const intentGeneration = this.beginPlaybackIntent()
|
||||
this.startPlaybackWithIntent(intentGeneration)
|
||||
},
|
||||
|
||||
requestSeek(targetValue, options = {}) {
|
||||
const context = this.audioContext
|
||||
if (
|
||||
!context
|
||||
|| this._unloaded
|
||||
|| this.data.loading
|
||||
|| this._pendingSeek
|
||||
) return false
|
||||
const duration = Math.max(
|
||||
1,
|
||||
Number(context.duration) || Number(this.data.durationSeconds) || 1,
|
||||
)
|
||||
const target = clamp(targetValue, 0, duration)
|
||||
const actualBeforeSeek = Number(context.currentTime)
|
||||
const previousValue = clamp(
|
||||
Number.isFinite(actualBeforeSeek)
|
||||
? actualBeforeSeek
|
||||
: Number(this.data.sliderValue) || 0,
|
||||
0,
|
||||
duration,
|
||||
)
|
||||
const operationGeneration = this._seekOperationGeneration + 1
|
||||
this._seekOperationGeneration = operationGeneration
|
||||
const pending = {
|
||||
operationGeneration,
|
||||
contextGeneration: this._contextGeneration,
|
||||
intentGeneration: this._intentGeneration,
|
||||
context,
|
||||
target,
|
||||
previousValue,
|
||||
playAfterSeek: Boolean(options.playAfterSeek),
|
||||
}
|
||||
this._pendingSeek = pending
|
||||
this._scrubbing = false
|
||||
this.setProgress(target, duration)
|
||||
this.setData({ seekLocked: true, error: '' })
|
||||
this._seekTimer = setTimeout(() => {
|
||||
this.failSeek(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
}, SEEK_TIMEOUT_MS)
|
||||
try {
|
||||
context.seek(target)
|
||||
} catch (_) {
|
||||
this.failSeek(
|
||||
operationGeneration,
|
||||
pending.contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
finishSeekFromEvent(context, contextGeneration) {
|
||||
const pending = this._pendingSeek
|
||||
if (!pending) return
|
||||
const actual = Number(context.currentTime)
|
||||
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
|
||||
this.finishSeek(
|
||||
pending.operationGeneration,
|
||||
contextGeneration,
|
||||
pending.intentGeneration,
|
||||
context,
|
||||
)
|
||||
},
|
||||
|
||||
finishSeek(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingSeek
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
) return
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._pendingSeek = null
|
||||
this.setData({ seekLocked: false })
|
||||
if (
|
||||
pending.playAfterSeek
|
||||
&& this._desiredPlayback
|
||||
&& this._activePlayIntentGeneration === intentGeneration
|
||||
) {
|
||||
this.startPlaybackWithIntent(intentGeneration)
|
||||
}
|
||||
},
|
||||
|
||||
failSeek(
|
||||
operationGeneration,
|
||||
contextGeneration,
|
||||
intentGeneration,
|
||||
context,
|
||||
) {
|
||||
const pending = this._pendingSeek
|
||||
if (
|
||||
!pending
|
||||
|| pending.operationGeneration !== operationGeneration
|
||||
|| pending.contextGeneration !== contextGeneration
|
||||
|| pending.intentGeneration !== intentGeneration
|
||||
|| pending.context !== context
|
||||
|| !this.isCurrentContext(context, contextGeneration)
|
||||
|| this._intentGeneration !== intentGeneration
|
||||
) return false
|
||||
if (this._seekTimer !== null) {
|
||||
clearTimeout(this._seekTimer)
|
||||
this._seekTimer = null
|
||||
}
|
||||
this._pendingSeek = null
|
||||
this._scrubbing = false
|
||||
const actual = Number(context.currentTime)
|
||||
const restoredValue = Number.isFinite(actual)
|
||||
? actual
|
||||
: pending.previousValue
|
||||
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
|
||||
this.setProgress(restoredValue, this.data.durationSeconds)
|
||||
this.setData({
|
||||
playing: pending.playAfterSeek ? false : this.data.playing,
|
||||
loading: false,
|
||||
seekLocked: false,
|
||||
error: '进度没有调好,请再试一次。',
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
previewSeek(event) {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
this._scrubbing = true
|
||||
this.setProgress(eventValue(event), this.data.durationSeconds)
|
||||
},
|
||||
|
||||
commitSeek(event) {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
this._scrubbing = false
|
||||
this.requestSeek(eventValue(event))
|
||||
},
|
||||
|
||||
rewindTenSeconds() {
|
||||
if (!this.audioContext || this.data.loading || this._pendingSeek) return
|
||||
const current = Number(this.audioContext.currentTime)
|
||||
const fallback = Number(this.data.sliderValue) || 0
|
||||
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
|
||||
},
|
||||
|
||||
replay() {
|
||||
if (
|
||||
!this.audioContext
|
||||
|| this.data.loading
|
||||
|| this._controlLocked
|
||||
|| this._pendingSeek
|
||||
|| this._unloaded
|
||||
) return
|
||||
const alreadyPlaying = this.data.playing && this._desiredPlayback
|
||||
const intentGeneration = alreadyPlaying
|
||||
? this._intentGeneration
|
||||
: this.beginPlaybackIntent()
|
||||
this.setData({ hasStarted: true, error: '' })
|
||||
this.requestSeek(0, {
|
||||
playAfterSeek: !alreadyPlaying,
|
||||
intentGeneration,
|
||||
})
|
||||
},
|
||||
|
||||
changePlaybackRate(event) {
|
||||
const requested = Number(
|
||||
event && event.currentTarget && event.currentTarget.dataset.rate,
|
||||
)
|
||||
if (
|
||||
!RATE_VALUES.includes(requested)
|
||||
|| !this.audioContext
|
||||
|| !this.data.rateControlsVisible
|
||||
) return
|
||||
const allowed = this.data.rateOptions.some((item) => item.value === requested)
|
||||
if (!allowed) return
|
||||
try {
|
||||
this.audioContext.playbackRate = requested
|
||||
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
|
||||
this.disableRateControls(this.audioContext)
|
||||
return
|
||||
}
|
||||
this.setData({ playbackRate: requested })
|
||||
} catch (_) {
|
||||
this.disableRateControls(this.audioContext)
|
||||
}
|
||||
},
|
||||
|
||||
retryLoad() {
|
||||
if (this.data.loading || this._controlLocked || this._unloaded) return
|
||||
if (this.audioContext) {
|
||||
const intentGeneration = this.beginPlaybackIntent()
|
||||
this.startPlaybackWithIntent(intentGeneration)
|
||||
return
|
||||
}
|
||||
const intentGeneration = this.beginPlaybackIntent()
|
||||
return this.prepareAudio(intentGeneration)
|
||||
},
|
||||
|
||||
goBack() {
|
||||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
|
||||
const returnToChapter = () => {
|
||||
if (typeof wx.reLaunch === 'function') {
|
||||
wx.reLaunch({
|
||||
url: chapterRoute(this.data.chapterNumber),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
|
||||
wx.navigateBack({ delta: 1, fail: returnToChapter })
|
||||
return
|
||||
}
|
||||
returnToChapter()
|
||||
},
|
||||
|
||||
destroyAudioContext() {
|
||||
const context = this.audioContext
|
||||
if (!context) return
|
||||
this.invalidatePlaybackIntent(false)
|
||||
this._contextGeneration += 1
|
||||
this.audioContext = null
|
||||
context.destroy()
|
||||
if (!this._unloaded) {
|
||||
this.setData({
|
||||
audioReady: false,
|
||||
playing: false,
|
||||
loading: false,
|
||||
rateOptions: FALLBACK_RATE_OPTIONS,
|
||||
rateControlsVisible: false,
|
||||
playbackRate: 1,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._unloaded = true
|
||||
this._lifecycleGeneration += 1
|
||||
this._prepareGeneration += 1
|
||||
this._preparePlayIntentGeneration = 0
|
||||
this._intentGeneration += 1
|
||||
this._activePlayIntentGeneration = 0
|
||||
this._desiredPlayback = false
|
||||
this._controlLocked = true
|
||||
this.clearPauseLock(false)
|
||||
this.clearSeekState(false)
|
||||
this.unbindAudioInterruptionHandlers()
|
||||
const context = this.audioContext
|
||||
this._contextGeneration += 1
|
||||
this.audioContext = null
|
||||
if (context) context.destroy()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<view class="audio-page">
|
||||
<view class="audio-topbar">
|
||||
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画">‹ 返回连环画</button>
|
||||
<view class="page-heading">
|
||||
<text class="page-kicker">{{chapterLabel}} · 有声夹页</text>
|
||||
<text class="page-count">{{pageNumber}}/8</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="audio-spread">
|
||||
<view class="story-card" aria-label="本页文字封面">
|
||||
<view class="story-card-border">
|
||||
<text class="story-card-kicker">桂香里的有声夹页</text>
|
||||
<text class="story-card-title">桂香里的这一页</text>
|
||||
<view class="story-card-rule"></view>
|
||||
<text class="story-card-chapter">{{chapterLabel}}</text>
|
||||
<text class="story-card-page">{{pageLabel}}</text>
|
||||
<text class="story-card-note">声音单独下载,画面仍留在原来的连环画里。</text>
|
||||
<text class="story-card-stamp">桂香故事</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="audio-copy">
|
||||
<text class="audio-title">听这一页</text>
|
||||
<text class="audio-caption">点“开始听”后才下载并播放;来电话或离开页面会暂停,不会自己续播。</text>
|
||||
|
||||
<view class="audio-seek-row">
|
||||
<slider
|
||||
class="audio-slider"
|
||||
aria-label="播放进度,可左右拖动"
|
||||
min="0"
|
||||
max="{{durationSeconds}}"
|
||||
step="1"
|
||||
value="{{sliderValue}}"
|
||||
activeColor="#9b3328"
|
||||
backgroundColor="#d6c6a1"
|
||||
block-color="#8f271f"
|
||||
block-size="24"
|
||||
disabled="{{loading || !audioReady || seekLocked}}"
|
||||
bindchanging="previewSeek"
|
||||
bindchange="commitSeek"
|
||||
></slider>
|
||||
<text class="audio-time">{{currentLabel}} / {{durationLabel}}</text>
|
||||
</view>
|
||||
|
||||
<view class="audio-controls">
|
||||
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
|
||||
<button class="audio-button primary" disabled="{{loading || seekLocked || error}}" bindtap="togglePlayback">
|
||||
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
|
||||
</button>
|
||||
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{rateControlsVisible}}" class="audio-rate-row" aria-label="播放速度">
|
||||
<text class="audio-rate-label">语速</text>
|
||||
<button
|
||||
wx:for="{{rateOptions}}"
|
||||
wx:key="value"
|
||||
class="audio-rate-button {{playbackRate == item.value ? 'active' : ''}}"
|
||||
data-rate="{{item.value}}"
|
||||
disabled="{{loading || seekLocked}}"
|
||||
bindtap="changePlaybackRate"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{error}}" class="audio-error" role="alert">
|
||||
<text>{{error}}</text>
|
||||
<button wx:if="{{retryAvailable}}" class="retry-button" disabled="{{loading}}" bindtap="retryLoad">再试一次</button>
|
||||
</view>
|
||||
<button class="back-bottom" bindtap="goBack">回到连环画</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,192 @@
|
||||
page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: #1c130f;
|
||||
color: #2f241d;
|
||||
}
|
||||
|
||||
button::after { border: 0; }
|
||||
|
||||
.audio-page {
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 320px;
|
||||
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: radial-gradient(circle at 48% 45%, #443125 0, #211611 70%);
|
||||
}
|
||||
|
||||
.audio-topbar {
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding-right: max(92px, env(safe-area-inset-right));
|
||||
color: #f5e6b7;
|
||||
}
|
||||
|
||||
.back-top {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 48px;
|
||||
border: 1px solid #9b7a51;
|
||||
border-radius: 10px;
|
||||
background: #38251b;
|
||||
color: #f7e8bd;
|
||||
font-size: 20px;
|
||||
line-height: 48px;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.page-heading { display: flex; align-items: baseline; gap: 12px; }
|
||||
.page-kicker { font-size: 20px; font-weight: 700; }
|
||||
.page-count { color: #d9bb78; font-size: 18px; }
|
||||
|
||||
.audio-spread {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
|
||||
border: 2px solid #b99762;
|
||||
background: #efe2bd;
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
|
||||
}
|
||||
|
||||
.story-card {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
background: repeating-linear-gradient(0deg, rgba(245, 225, 177, .035) 0, rgba(245, 225, 177, .035) 1px, transparent 1px, transparent 5px), #251813;
|
||||
border-right: 4px solid #8d271f;
|
||||
}
|
||||
|
||||
.story-card-border {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #b99762;
|
||||
outline: 1px solid rgba(185, 151, 98, .55);
|
||||
outline-offset: -8px;
|
||||
color: #f4e3b8;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.story-card-kicker { color: #d1b274; font-size: 17px; letter-spacing: 3px; }
|
||||
.story-card-title { margin-top: 10px; font-size: 32px; font-weight: 800; letter-spacing: 5px; }
|
||||
.story-card-rule { width: 58%; height: 2px; margin: 16px 0; background: #8f3027; }
|
||||
.story-card-chapter { color: #f8eac8; font-size: 28px; font-weight: 800; }
|
||||
.story-card-page { margin-top: 6px; color: #d8bd83; font-size: 22px; }
|
||||
.story-card-note { max-width: 430px; margin-top: 20px; color: #d8c8a5; font-size: 18px; line-height: 1.55; }
|
||||
.story-card-stamp { position: absolute; right: 22px; bottom: 18px; padding: 6px 10px; border: 2px solid #9d3a2e; color: #d98673; font-size: 16px; letter-spacing: 2px; }
|
||||
|
||||
.audio-copy {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 18px 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
|
||||
}
|
||||
|
||||
.audio-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
|
||||
.audio-caption { margin-top: 10px; font-size: 20px; font-weight: 600; line-height: 1.48; }
|
||||
.audio-seek-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.audio-slider { min-width: 0; width: 100%; margin: 0; }
|
||||
.audio-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
|
||||
.audio-controls {
|
||||
min-width: 0;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.audio-button,
|
||||
.retry-button,
|
||||
.back-bottom {
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
border: 2px solid #7e6648;
|
||||
border-radius: 10px;
|
||||
background: #eadbb5;
|
||||
color: #37291f;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.audio-button { width: 100%; max-width: 100%; }
|
||||
.audio-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
|
||||
.audio-button[disabled], .retry-button[disabled] { opacity: .72; }
|
||||
.audio-rate-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.audio-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
|
||||
.audio-rate-button {
|
||||
box-sizing: border-box;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border: 2px solid #9d896a;
|
||||
border-radius: 9px;
|
||||
background: #f3e7c8;
|
||||
color: #4d3b2d;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
}
|
||||
.audio-rate-button { width: 100%; max-width: 100%; }
|
||||
.audio-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
|
||||
.audio-rate-button[disabled] { opacity: .72; }
|
||||
.audio-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
|
||||
.retry-button { width: 100%; min-height: 48px; margin-top: 8px; line-height: 46px; font-size: 18px; }
|
||||
.back-bottom { margin-top: 8px; width: 100%; }
|
||||
|
||||
@media (max-height: 430px) {
|
||||
.audio-page { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
|
||||
.audio-topbar { height: 48px; flex-basis: 48px; }
|
||||
.story-card { padding: 10px; }
|
||||
.story-card-border { padding: 16px; }
|
||||
.story-card-title { font-size: 26px; }
|
||||
.story-card-note { margin-top: 10px; font-size: 16px; }
|
||||
.audio-copy { padding: 8px 12px; overflow-y: auto; }
|
||||
.audio-title { font-size: 23px; }
|
||||
.audio-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
|
||||
.audio-seek-row { min-height: 48px; }
|
||||
.audio-controls, .audio-rate-row { margin-top: 3px; gap: 5px; }
|
||||
.audio-button, .retry-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
|
||||
.audio-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
|
||||
.audio-rate-label { font-size: 16px; }
|
||||
.back-bottom { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
const CACHE_INDEX_VERSION = 2
|
||||
const CACHE_FOLDER = 'tang-detective-assets-v2'
|
||||
const CACHE_INDEX_FILE = 'index.json'
|
||||
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_MAX_ENTRIES = 32
|
||||
|
||||
const SHA256_ROUND_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function utf8Bytes(value) {
|
||||
const encoded = encodeURIComponent(String(value || ''))
|
||||
const bytes = []
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] === '%') {
|
||||
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
|
||||
index += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
if (typeof value === 'string') return utf8Bytes(value)
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
throw new Error('unsupported SHA-256 input')
|
||||
}
|
||||
|
||||
function rotateRight(value, bits) {
|
||||
return (value >>> bits) | (value << (32 - bits))
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
const bytes = toBytes(value)
|
||||
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||
const message = new Uint8Array(paddedLength)
|
||||
message.set(bytes)
|
||||
message[bytes.length] = 0x80
|
||||
const bitLength = bytes.length * 8
|
||||
const highBits = Math.floor(bitLength / 0x100000000)
|
||||
const lowBits = bitLength >>> 0
|
||||
const lengthOffset = paddedLength - 8
|
||||
message[lengthOffset] = (highBits >>> 24) & 0xff
|
||||
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
|
||||
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
|
||||
message[lengthOffset + 3] = highBits & 0xff
|
||||
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
|
||||
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
|
||||
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
|
||||
message[lengthOffset + 7] = lowBits & 0xff
|
||||
|
||||
const hash = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]
|
||||
const words = new Uint32Array(64)
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const start = offset + index * 4
|
||||
words[index] = (
|
||||
(message[start] << 24)
|
||||
| (message[start + 1] << 16)
|
||||
| (message[start + 2] << 8)
|
||||
| message[start + 3]
|
||||
) >>> 0
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const word15 = words[index - 15]
|
||||
const word2 = words[index - 2]
|
||||
const sigma0 = (
|
||||
rotateRight(word15, 7)
|
||||
^ rotateRight(word15, 18)
|
||||
^ (word15 >>> 3)
|
||||
)
|
||||
const sigma1 = (
|
||||
rotateRight(word2, 17)
|
||||
^ rotateRight(word2, 19)
|
||||
^ (word2 >>> 10)
|
||||
)
|
||||
words[index] = (
|
||||
words[index - 16]
|
||||
+ sigma0
|
||||
+ words[index - 7]
|
||||
+ sigma1
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
let a = hash[0]
|
||||
let b = hash[1]
|
||||
let c = hash[2]
|
||||
let d = hash[3]
|
||||
let e = hash[4]
|
||||
let f = hash[5]
|
||||
let g = hash[6]
|
||||
let h = hash[7]
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
||||
const choice = (e & f) ^ (~e & g)
|
||||
const temp1 = (
|
||||
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
|
||||
) >>> 0
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (sum0 + majority) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0
|
||||
hash[1] = (hash[1] + b) >>> 0
|
||||
hash[2] = (hash[2] + c) >>> 0
|
||||
hash[3] = (hash[3] + d) >>> 0
|
||||
hash[4] = (hash[4] + e) >>> 0
|
||||
hash[5] = (hash[5] + f) >>> 0
|
||||
hash[6] = (hash[6] + g) >>> 0
|
||||
hash[7] = (hash[7] + h) >>> 0
|
||||
}
|
||||
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||
}
|
||||
|
||||
function constantTimeEqualHex(left, right) {
|
||||
const leftValue = String(left || '').toLowerCase()
|
||||
const rightValue = String(right || '').toLowerCase()
|
||||
const length = Math.max(leftValue.length, rightValue.length)
|
||||
let difference = leftValue.length ^ rightValue.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
|
||||
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
|
||||
difference |= leftCode ^ rightCode
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function joinPath(left, right) {
|
||||
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function normalizeIndex(value) {
|
||||
if (
|
||||
!value
|
||||
|| value.version !== CACHE_INDEX_VERSION
|
||||
|| !value.entries
|
||||
|| typeof value.entries !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: CACHE_INDEX_VERSION,
|
||||
entries: {},
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function callFs(fs, method, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs || typeof fs[method] !== 'function') {
|
||||
reject(new Error(`fs.${method} unavailable`))
|
||||
return
|
||||
}
|
||||
fs[method]({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createAssetPlatformFacade() {
|
||||
if (typeof wx === 'undefined') return null
|
||||
const env = Object.freeze({
|
||||
USER_DATA_PATH: String(
|
||||
wx.env && wx.env.USER_DATA_PATH || '',
|
||||
),
|
||||
})
|
||||
return Object.freeze({
|
||||
downloadFile(options) {
|
||||
if (typeof wx.downloadFile !== 'function') {
|
||||
if (options && typeof options.fail === 'function') {
|
||||
options.fail(new Error('wx.downloadFile unavailable'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return wx.downloadFile(options)
|
||||
},
|
||||
getFileSystemManager() {
|
||||
if (typeof wx.getFileSystemManager !== 'function') return null
|
||||
return wx.getFileSystemManager()
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
function download(assetPlatform, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
|
||||
reject(new Error('wx.downloadFile unavailable'))
|
||||
return
|
||||
}
|
||||
assetPlatform.downloadFile({
|
||||
url,
|
||||
success(result) {
|
||||
if (
|
||||
result
|
||||
&& result.statusCode >= 200
|
||||
&& result.statusCode < 300
|
||||
&& result.tempFilePath
|
||||
) {
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
reject(new Error(`download status ${result && result.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function safeCacheName(assetId, asset) {
|
||||
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
|
||||
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
|
||||
}
|
||||
|
||||
function createAssetManager(options = {}) {
|
||||
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
|
||||
const manifest = options.manifest || {}
|
||||
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
|
||||
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
|
||||
? Math.max(0, options.imageCacheBudgetBytes)
|
||||
: DEFAULT_IMAGE_BUDGET
|
||||
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
|
||||
? Math.max(0, options.audioCacheBudgetBytes)
|
||||
: DEFAULT_AUDIO_BUDGET
|
||||
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
|
||||
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
|
||||
: DEFAULT_AUDIO_MAX_ENTRIES
|
||||
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
|
||||
? Math.max(1, Math.floor(options.downloadConcurrency))
|
||||
: 2
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now
|
||||
const fs = assetPlatform
|
||||
&& typeof assetPlatform.getFileSystemManager === 'function'
|
||||
? assetPlatform.getFileSystemManager()
|
||||
: null
|
||||
const userDataPath = (
|
||||
assetPlatform
|
||||
&& assetPlatform.env
|
||||
&& assetPlatform.env.USER_DATA_PATH
|
||||
) || ''
|
||||
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
|
||||
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
|
||||
|
||||
let initialized = false
|
||||
let index = normalizeIndex(null)
|
||||
let activeDownloads = 0
|
||||
const inflight = new Map()
|
||||
const downloadWaiters = []
|
||||
|
||||
function fallback(assetId, reason) {
|
||||
return {
|
||||
assetId,
|
||||
available: false,
|
||||
uri: '',
|
||||
source: 'text-fallback',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCacheFolder() {
|
||||
if (!cacheRoot) return false
|
||||
try {
|
||||
await callFs(fs, 'mkdir', {
|
||||
dirPath: cacheRoot,
|
||||
recursive: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// EEXIST and older base-library mkdir failures are both safe to ignore.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function persistIndex() {
|
||||
if (!indexPath) return
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
await callFs(fs, 'writeFile', {
|
||||
filePath: indexPath,
|
||||
data: JSON.stringify(index),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
} catch (error) {
|
||||
// Cache metadata must never block the text-first game.
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
await callFs(fs, 'access', { path: filePath })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMatchesSha256(filePath, expectedSha256) {
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', { filePath })
|
||||
return constantTimeEqualHex(
|
||||
sha256Hex(result.data),
|
||||
expectedSha256,
|
||||
)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (!indexPath) return
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', {
|
||||
filePath: indexPath,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
index = normalizeIndex(JSON.parse(result.data))
|
||||
} catch (error) {
|
||||
index = normalizeIndex(null)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [assetId, entry] of Object.entries(index.entries)) {
|
||||
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await persistIndex()
|
||||
await enforceImageBudget()
|
||||
await enforceAudioBudget()
|
||||
}
|
||||
|
||||
function cacheBytes(kind) {
|
||||
return Object.values(index.entries).reduce(
|
||||
(total, entry) => (
|
||||
entry.kind === kind
|
||||
? total + Math.max(0, Number(entry.size) || 0)
|
||||
: total
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function cacheEntries(kind) {
|
||||
return Object.values(index.entries)
|
||||
.filter((entry) => entry.kind === kind)
|
||||
.length
|
||||
}
|
||||
|
||||
function imageCacheBytes() {
|
||||
return cacheBytes('image')
|
||||
}
|
||||
|
||||
function audioCacheBytes() {
|
||||
return cacheBytes('audio')
|
||||
}
|
||||
|
||||
async function unlinkQuietly(filePath) {
|
||||
try {
|
||||
await callFs(fs, 'unlink', { filePath })
|
||||
} catch (error) {
|
||||
// A missing cache file is already evicted.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceImageBudget(protectedAssetId = '') {
|
||||
let total = imageCacheBytes()
|
||||
if (total <= imageBudgetBytes) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'image' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= imageBudgetBytes) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function enforceAudioBudget(protectedAssetId = '') {
|
||||
let total = audioCacheBytes()
|
||||
let entries = cacheEntries('audio')
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'audio' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
entries -= 1
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function resolveCached(assetId, asset) {
|
||||
const entry = index.entries[assetId]
|
||||
if (!entry || entry.sha256 !== asset.sha256) return null
|
||||
if (!(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return null
|
||||
}
|
||||
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
|
||||
await unlinkQuietly(entry.filePath)
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
entry.lastAccessedAt = now()
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: entry.filePath,
|
||||
source: 'cache',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTempFileSize(tempFilePath, response) {
|
||||
if (Number.isFinite(response.fileSize)) return response.fileSize
|
||||
try {
|
||||
const statResult = await callFs(fs, 'stat', {
|
||||
path: tempFilePath,
|
||||
})
|
||||
const stat = statResult && statResult.stats
|
||||
return Math.max(0, Number(stat && stat.size) || 0)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(assetId, asset) {
|
||||
if (!cdnBaseUrl || !asset.remotePath) {
|
||||
return fallback(assetId, 'remote-disabled')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
activeDownloads += 1
|
||||
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1
|
||||
const next = downloadWaiters.shift()
|
||||
if (next) next()
|
||||
})
|
||||
}
|
||||
if (activeDownloads < downloadConcurrency) run()
|
||||
else downloadWaiters.push(run)
|
||||
})
|
||||
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
|
||||
await unlinkQuietly(response.tempFilePath)
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
const size = await getTempFileSize(response.tempFilePath, response)
|
||||
|
||||
const exceedsCachePolicy = (
|
||||
(asset.kind === 'image' && size > imageBudgetBytes)
|
||||
|| (
|
||||
asset.kind === 'audio'
|
||||
&& (size > audioBudgetBytes || audioMaxEntries < 1)
|
||||
)
|
||||
)
|
||||
if (!cacheRoot || exceedsCachePolicy) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
|
||||
await callFs(fs, 'saveFile', {
|
||||
tempFilePath: response.tempFilePath,
|
||||
filePath,
|
||||
})
|
||||
index.entries[assetId] = {
|
||||
assetId,
|
||||
filePath,
|
||||
kind: asset.kind,
|
||||
sha256: asset.sha256,
|
||||
size,
|
||||
lastAccessedAt: now(),
|
||||
}
|
||||
await enforceImageBudget(assetId)
|
||||
await enforceAudioBudget(assetId)
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: filePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(assetId, resolveOptions = {}) {
|
||||
await init()
|
||||
const asset = manifest[assetId]
|
||||
if (!asset) return fallback(assetId, 'unknown-asset')
|
||||
if (
|
||||
asset.kind === 'audio'
|
||||
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
|
||||
) {
|
||||
return fallback(assetId, 'audio-unapproved')
|
||||
}
|
||||
|
||||
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
|
||||
if (asset.localSeed && resolveOptions.preferRemote !== true) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
const cached = await resolveCached(assetId, asset)
|
||||
if (cached) return cached
|
||||
|
||||
if (resolveOptions.allowRemote === false) {
|
||||
if (asset.localSeed) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
return fallback(assetId, 'remote-disallowed')
|
||||
}
|
||||
|
||||
if (inflight.has(assetId)) return inflight.get(assetId)
|
||||
|
||||
const request = fetchRemote(assetId, asset)
|
||||
.catch(() => (
|
||||
asset.localSeed
|
||||
? {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
: fallback(assetId, 'remote-failed')
|
||||
))
|
||||
.finally(() => inflight.delete(assetId))
|
||||
inflight.set(assetId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function prefetch(assetIds) {
|
||||
const ids = Array.isArray(assetIds) ? assetIds : []
|
||||
return Promise.all(ids.map((assetId) => resolve(assetId)))
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await init()
|
||||
const entries = Object.values(index.entries)
|
||||
for (const entry of entries) await unlinkQuietly(entry.filePath)
|
||||
index = normalizeIndex(null)
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return {
|
||||
entries: Object.keys(index.entries).length,
|
||||
imageBytes: imageCacheBytes(),
|
||||
imageBudgetBytes,
|
||||
audioEntries: cacheEntries('audio'),
|
||||
audioBytes: audioCacheBytes(),
|
||||
audioBudgetBytes,
|
||||
audioMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
resolve,
|
||||
prefetch,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
getAsset(assetId) {
|
||||
return manifest[assetId] || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_INDEX_VERSION,
|
||||
DEFAULT_AUDIO_BUDGET,
|
||||
DEFAULT_AUDIO_MAX_ENTRIES,
|
||||
constantTimeEqualHex,
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
sha256Hex,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const SHARED_GAME_CHAPTERS = new Set([1])
|
||||
|
||||
function normalizeChapterNumber(value) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return 1
|
||||
return Math.min(15, Math.max(1, Math.floor(number)))
|
||||
}
|
||||
|
||||
function chapterPackageRoot(value) {
|
||||
const chapter = normalizeChapterNumber(value)
|
||||
if (SHARED_GAME_CHAPTERS.has(chapter)) return 'package-game'
|
||||
return `package-chapter-${String(chapter).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function chapterRoute(value, options = {}) {
|
||||
const chapter = normalizeChapterNumber(value)
|
||||
const replay = options && options.replay === true ? '&replay=1' : ''
|
||||
return `/${chapterPackageRoot(chapter)}/pages/chapter/chapter?chapter=${chapter}${replay}`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeChapterNumber,
|
||||
chapterPackageRoot,
|
||||
chapterRoute,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 发布环境可覆盖这些值。
|
||||
*
|
||||
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
|
||||
* 使用包内种子图或返回文字回退,不会发起网络请求。
|
||||
*/
|
||||
module.exports = {
|
||||
cdnBaseUrl: '',
|
||||
imageCacheBudgetBytes: 28 * 1024 * 1024,
|
||||
audioCacheBudgetBytes: 32 * 1024 * 1024,
|
||||
audioCacheMaxEntries: 32,
|
||||
prefetchAhead: 2,
|
||||
downloadConcurrency: 2,
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
const { releaseAssets } = require('./releaseAssetManifest')
|
||||
|
||||
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
|
||||
const RUNTIME_VISUAL_TIERS = Object.freeze({
|
||||
FORMAL: 'formal',
|
||||
PROVISIONAL: 'experience-provisional',
|
||||
FALLBACK: 'fallback',
|
||||
})
|
||||
|
||||
// This set deliberately mirrors `usable` in
|
||||
// docs/production/art-release-gate-s01.json. Registered page art outside this
|
||||
// set may be shown only as experience-provisional and never counts as formal.
|
||||
const FORMAL_PAGE_ART_IDS = new Set([
|
||||
'S01-C01-P01',
|
||||
'S01-C01-P02',
|
||||
'S01-C01-P03',
|
||||
'S01-C01-P04',
|
||||
'S01-C01-P05',
|
||||
'S01-C01-P07',
|
||||
'S01-C01-P08',
|
||||
'S01-C02-P01',
|
||||
'S01-C02-P02',
|
||||
'S01-C02-P03',
|
||||
'S01-C02-P04',
|
||||
'S01-C02-P05',
|
||||
'S01-C02-P06',
|
||||
'S01-C02-P07',
|
||||
'S01-C02-P08',
|
||||
'S01-C03-P01',
|
||||
'S01-C03-P02',
|
||||
'S01-C03-P03',
|
||||
'S01-C03-P04',
|
||||
'S01-C03-P05',
|
||||
'S01-C03-P07',
|
||||
'S01-C03-P08',
|
||||
'S01-C04-P01',
|
||||
'S01-C04-P02',
|
||||
'S01-C04-P03',
|
||||
'S01-C04-P05',
|
||||
'S01-C04-P06',
|
||||
'S01-C04-P07',
|
||||
'S01-C04-P08',
|
||||
'S01-C05-P01',
|
||||
'S01-C05-P02',
|
||||
'S01-C05-P03',
|
||||
'S01-C05-P04',
|
||||
'S01-C05-P05',
|
||||
'S01-C05-P06',
|
||||
'S01-C05-P07',
|
||||
'S01-C05-P08',
|
||||
'S01-C06-P01',
|
||||
'S01-C06-P02',
|
||||
'S01-C06-P03',
|
||||
'S01-C06-P04',
|
||||
'S01-C06-P05',
|
||||
'S01-C06-P06',
|
||||
'S01-C06-P07',
|
||||
'S01-C06-P08',
|
||||
'S01-C07-P01',
|
||||
'S01-C07-P02',
|
||||
'S01-C07-P03',
|
||||
'S01-C07-P04',
|
||||
'S01-C07-P05',
|
||||
'S01-C07-P06',
|
||||
'S01-C07-P07',
|
||||
'S01-C07-P08',
|
||||
'S01-C08-P02',
|
||||
'S01-C08-P03',
|
||||
'S01-C08-P04',
|
||||
'S01-C08-P05',
|
||||
'S01-C08-P06',
|
||||
'S01-C08-P07',
|
||||
'S01-C08-P08',
|
||||
'S01-C09-P02',
|
||||
'S01-C09-P03',
|
||||
'S01-C09-P04',
|
||||
'S01-C09-P07',
|
||||
'S01-C09-P08',
|
||||
'S01-C10-P01',
|
||||
'S01-C10-P02',
|
||||
'S01-C10-P03',
|
||||
'S01-C10-P04',
|
||||
'S01-C10-P05',
|
||||
'S01-C10-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C10-P08',
|
||||
'S01-C11-P01',
|
||||
'S01-C11-P02',
|
||||
'S01-C11-P03',
|
||||
'S01-C11-P04',
|
||||
'S01-C11-P05',
|
||||
'S01-C11-P06',
|
||||
'S01-C11-P07',
|
||||
'S01-C11-P08',
|
||||
'S01-C12-P01',
|
||||
'S01-C12-P02',
|
||||
'S01-C12-P03',
|
||||
'S01-C12-P04',
|
||||
'S01-C12-P05',
|
||||
'S01-C12-P06',
|
||||
'S01-C12-P07',
|
||||
'S01-C12-P08',
|
||||
'S01-C13-P01',
|
||||
'S01-C13-P02',
|
||||
'S01-C13-P03',
|
||||
'S01-C13-P05',
|
||||
'S01-C13-P06',
|
||||
'S01-C13-P07',
|
||||
'S01-C13-P08',
|
||||
'S01-C14-P01',
|
||||
'S01-C14-P02',
|
||||
'S01-C14-P03',
|
||||
'S01-C14-P04',
|
||||
'S01-C14-P05',
|
||||
'S01-C14-P06',
|
||||
'S01-C14-P07',
|
||||
'S01-C14-P08',
|
||||
'S01-C15-P01',
|
||||
'S01-C15-P02',
|
||||
'S01-C15-P03',
|
||||
'S01-C15-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P06',
|
||||
'S01-C15-P07',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicAssetIdForPageId(pageId) {
|
||||
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
|
||||
if (!match) return ''
|
||||
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
|
||||
}
|
||||
|
||||
function registeredPageArtFor(page) {
|
||||
const assetId = comicAssetIdForPageId(page && page.pageId)
|
||||
const releaseAsset = assetId ? releaseAssets[assetId] : null
|
||||
return Boolean(
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
&& clean(releaseAsset.localSeed)
|
||||
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
|
||||
)
|
||||
}
|
||||
|
||||
function createPageArtVisual(page, chapter, runtimeTier) {
|
||||
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
|
||||
return {
|
||||
kind: 'page-art',
|
||||
asset: clean(page.illustrationAsset),
|
||||
subject: clean(page.actorName || page.headline || chapter.title),
|
||||
runtimeTier,
|
||||
formalReleaseEligible: formal,
|
||||
reviewStatus: formal
|
||||
? PLAYABLE_VISUAL_REVIEW_STATUS
|
||||
: PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: formal
|
||||
? 'art-release-gate-s01-usable'
|
||||
: 'registered-immutable-asset-experience-only',
|
||||
}
|
||||
}
|
||||
|
||||
function personByInstanceId(chapter, instanceId) {
|
||||
return (chapter.people || []).find(
|
||||
(person) => (
|
||||
person.instanceId === instanceId
|
||||
&& clean(person.portrait)
|
||||
),
|
||||
) || null
|
||||
}
|
||||
|
||||
function personByName(chapter, name) {
|
||||
const cleanName = clean(name)
|
||||
if (!cleanName) return null
|
||||
return (chapter.people || []).find(
|
||||
(person) => person.name === cleanName && clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function featuredPersonForPage(page, chapter) {
|
||||
const direct = personByInstanceId(chapter, page.actorInstanceId)
|
||||
if (direct) return direct
|
||||
|
||||
if (page.type === 'memory') {
|
||||
const memoryPerson = personByName(
|
||||
chapter,
|
||||
page.memoryCard && page.memoryCard.characterName,
|
||||
)
|
||||
if (memoryPerson) return memoryPerson
|
||||
}
|
||||
|
||||
if (page.type === 'emotion') {
|
||||
const emotionPerson = personByName(
|
||||
chapter,
|
||||
chapter.emotionMoment
|
||||
&& chapter.emotionMoment.character
|
||||
&& chapter.emotionMoment.character.name,
|
||||
)
|
||||
if (emotionPerson) return emotionPerson
|
||||
}
|
||||
|
||||
return (chapter.people || []).find(
|
||||
(person) => clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function createPlayableVisual(page, chapter) {
|
||||
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
|
||||
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
|
||||
}
|
||||
|
||||
if (registeredPageArtFor(page)) {
|
||||
return createPageArtVisual(
|
||||
page,
|
||||
chapter,
|
||||
RUNTIME_VISUAL_TIERS.PROVISIONAL,
|
||||
)
|
||||
}
|
||||
|
||||
if (page.type === 'event') {
|
||||
const portrait = clean(page.actorPortrait)
|
||||
if (portrait) {
|
||||
return {
|
||||
kind: 'actor-portrait',
|
||||
asset: portrait,
|
||||
subject: clean(page.actorName),
|
||||
actorInstanceId: clean(page.actorInstanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.actorName),
|
||||
evidenceLabel: clean(page.headline || '看看手边证据'),
|
||||
evidenceDetail: clean(
|
||||
page.secondaryCaption || page.caption || page.question,
|
||||
),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPerson = featuredPersonForPage(page, chapter)
|
||||
if (featuredPerson) {
|
||||
return {
|
||||
kind: 'chapter-character',
|
||||
asset: clean(featuredPerson.portrait),
|
||||
subject: clean(featuredPerson.name),
|
||||
actorInstanceId: clean(featuredPerson.instanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive last resort. The season currently never reaches this branch,
|
||||
// but if a future chapter lacks a portrait it still gets explicit evidence
|
||||
// instead of an unexplained empty room.
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.headline || chapter.title),
|
||||
evidenceLabel: clean(page.headline || chapter.title),
|
||||
evidenceDetail: clean(page.caption || chapter.narration),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
function attachPlayableVisuals(pageSequence, chapter) {
|
||||
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
|
||||
...page,
|
||||
playableVisual: createPlayableVisual(page, chapter),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORMAL_PAGE_ART_IDS,
|
||||
PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
RUNTIME_VISUAL_TIERS,
|
||||
attachPlayableVisuals,
|
||||
createPlayableVisual,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Generated package-local production comic data. Do not hand-edit.
|
||||
module.exports = {}
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* 发布固定资产清单 v1
|
||||
*
|
||||
* 约束:
|
||||
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
|
||||
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
|
||||
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
|
||||
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
|
||||
*/
|
||||
|
||||
const RELEASE_ASSET_MANIFEST_VERSION = 1
|
||||
|
||||
const releaseAssets = {
|
||||
'comic.s01.c01.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
|
||||
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
|
||||
},
|
||||
'comic.s01.c01.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
|
||||
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
|
||||
},
|
||||
'comic.s01.c01.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
|
||||
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
|
||||
},
|
||||
'comic.s01.c01.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
|
||||
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
|
||||
},
|
||||
'comic.s01.c01.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
|
||||
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
|
||||
},
|
||||
'comic.s01.c01.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
|
||||
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
|
||||
},
|
||||
'comic.s01.c01.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
|
||||
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
|
||||
},
|
||||
'comic.s01.c01.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
|
||||
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
|
||||
},
|
||||
'comic.s01.c02.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
|
||||
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
|
||||
},
|
||||
'comic.s01.c02.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
|
||||
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
|
||||
},
|
||||
'comic.s01.c02.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
|
||||
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
|
||||
},
|
||||
'comic.s01.c02.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
|
||||
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
|
||||
},
|
||||
'comic.s01.c02.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
|
||||
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
|
||||
},
|
||||
'comic.s01.c02.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
|
||||
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
|
||||
},
|
||||
'comic.s01.c02.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
|
||||
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
|
||||
},
|
||||
'comic.s01.c02.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
|
||||
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
|
||||
},
|
||||
'comic.s01.c03.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
|
||||
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
|
||||
},
|
||||
'comic.s01.c03.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
|
||||
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
|
||||
},
|
||||
'comic.s01.c03.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
|
||||
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
|
||||
},
|
||||
'comic.s01.c03.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
|
||||
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
|
||||
},
|
||||
'comic.s01.c03.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
|
||||
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
|
||||
},
|
||||
'comic.s01.c03.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
|
||||
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
|
||||
},
|
||||
'comic.s01.c03.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
|
||||
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
|
||||
},
|
||||
'comic.s01.c03.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
|
||||
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
|
||||
},
|
||||
'comic.s01.c04.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
|
||||
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
|
||||
},
|
||||
'comic.s01.c04.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
|
||||
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
|
||||
},
|
||||
'comic.s01.c04.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
|
||||
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
|
||||
},
|
||||
'comic.s01.c04.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
|
||||
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
|
||||
},
|
||||
'comic.s01.c04.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
|
||||
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
|
||||
},
|
||||
'comic.s01.c04.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
|
||||
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
|
||||
},
|
||||
'comic.s01.c04.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
|
||||
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
|
||||
},
|
||||
'comic.s01.c04.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
|
||||
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
|
||||
},
|
||||
'comic.s01.c05.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
|
||||
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
|
||||
},
|
||||
'comic.s01.c05.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
|
||||
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
|
||||
},
|
||||
'comic.s01.c05.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
|
||||
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
|
||||
},
|
||||
'comic.s01.c05.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
|
||||
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
|
||||
},
|
||||
'comic.s01.c05.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
|
||||
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
|
||||
},
|
||||
'comic.s01.c05.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
|
||||
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
|
||||
},
|
||||
'comic.s01.c05.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
|
||||
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
|
||||
},
|
||||
'comic.s01.c05.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
|
||||
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
|
||||
},
|
||||
'comic.s01.c06.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
|
||||
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
|
||||
},
|
||||
'comic.s01.c06.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
|
||||
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
|
||||
},
|
||||
'comic.s01.c06.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
|
||||
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
|
||||
},
|
||||
'comic.s01.c06.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
|
||||
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
|
||||
},
|
||||
'comic.s01.c06.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
|
||||
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
|
||||
},
|
||||
'comic.s01.c06.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
|
||||
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
|
||||
},
|
||||
'comic.s01.c06.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
|
||||
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
|
||||
},
|
||||
'comic.s01.c06.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
|
||||
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
|
||||
},
|
||||
'comic.s01.c07.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
|
||||
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
|
||||
},
|
||||
'comic.s01.c07.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
|
||||
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
|
||||
},
|
||||
'comic.s01.c07.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
|
||||
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
|
||||
},
|
||||
'comic.s01.c07.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
|
||||
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
|
||||
},
|
||||
'comic.s01.c07.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
|
||||
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
|
||||
},
|
||||
'comic.s01.c07.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
|
||||
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
|
||||
},
|
||||
'comic.s01.c07.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
|
||||
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
|
||||
},
|
||||
'comic.s01.c07.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
|
||||
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
|
||||
},
|
||||
'comic.s01.c08.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
|
||||
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
|
||||
},
|
||||
'comic.s01.c08.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
|
||||
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
|
||||
},
|
||||
'comic.s01.c08.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
|
||||
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
|
||||
},
|
||||
'comic.s01.c08.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
|
||||
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
|
||||
},
|
||||
'comic.s01.c08.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
|
||||
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
|
||||
},
|
||||
'comic.s01.c08.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
|
||||
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
|
||||
},
|
||||
'comic.s01.c08.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
|
||||
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
|
||||
},
|
||||
'comic.s01.c08.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
|
||||
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
|
||||
},
|
||||
'comic.s01.c09.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
|
||||
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
|
||||
},
|
||||
'comic.s01.c09.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
|
||||
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
|
||||
},
|
||||
'comic.s01.c09.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
|
||||
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
|
||||
},
|
||||
'comic.s01.c09.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
|
||||
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
|
||||
},
|
||||
'comic.s01.c09.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
|
||||
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
|
||||
},
|
||||
'comic.s01.c09.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
|
||||
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
|
||||
},
|
||||
'comic.s01.c09.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
|
||||
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
|
||||
},
|
||||
'comic.s01.c09.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
|
||||
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
|
||||
},
|
||||
'comic.s01.c10.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
|
||||
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
|
||||
},
|
||||
'comic.s01.c10.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
|
||||
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
|
||||
},
|
||||
'comic.s01.c10.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
|
||||
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
|
||||
},
|
||||
'comic.s01.c10.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
|
||||
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
|
||||
},
|
||||
'comic.s01.c10.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
|
||||
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
|
||||
},
|
||||
'comic.s01.c10.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
|
||||
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
|
||||
},
|
||||
'comic.s01.c10.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
|
||||
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
|
||||
},
|
||||
'comic.s01.c10.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
|
||||
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
|
||||
},
|
||||
'comic.s01.c11.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
|
||||
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
|
||||
},
|
||||
'comic.s01.c11.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
|
||||
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
|
||||
},
|
||||
'comic.s01.c11.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
|
||||
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
|
||||
},
|
||||
'comic.s01.c11.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
|
||||
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
|
||||
},
|
||||
'comic.s01.c11.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
|
||||
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
|
||||
},
|
||||
'comic.s01.c11.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
|
||||
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
|
||||
},
|
||||
'comic.s01.c11.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
|
||||
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
|
||||
},
|
||||
'comic.s01.c11.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
|
||||
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
|
||||
},
|
||||
'comic.s01.c12.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
|
||||
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
|
||||
},
|
||||
'comic.s01.c12.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
|
||||
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
|
||||
},
|
||||
'comic.s01.c12.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
|
||||
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
|
||||
},
|
||||
'comic.s01.c12.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
|
||||
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
|
||||
},
|
||||
'comic.s01.c12.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
|
||||
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
|
||||
},
|
||||
'comic.s01.c12.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
|
||||
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
|
||||
},
|
||||
'comic.s01.c12.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
|
||||
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
|
||||
},
|
||||
'comic.s01.c12.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
|
||||
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
|
||||
},
|
||||
'comic.s01.c13.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
|
||||
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
|
||||
},
|
||||
'comic.s01.c13.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
|
||||
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
|
||||
},
|
||||
'comic.s01.c13.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
|
||||
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
|
||||
},
|
||||
'comic.s01.c13.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
|
||||
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
|
||||
},
|
||||
'comic.s01.c13.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
|
||||
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
|
||||
},
|
||||
'comic.s01.c13.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
|
||||
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
|
||||
},
|
||||
'comic.s01.c13.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
|
||||
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
|
||||
},
|
||||
'comic.s01.c13.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
|
||||
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
|
||||
},
|
||||
'comic.s01.c14.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
|
||||
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
|
||||
},
|
||||
'comic.s01.c14.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
|
||||
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
|
||||
},
|
||||
'comic.s01.c14.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
|
||||
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
|
||||
},
|
||||
'comic.s01.c14.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
|
||||
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
|
||||
},
|
||||
'comic.s01.c14.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
|
||||
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
|
||||
},
|
||||
'comic.s01.c14.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
|
||||
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
|
||||
},
|
||||
'comic.s01.c14.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
|
||||
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
|
||||
},
|
||||
'comic.s01.c14.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
|
||||
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
|
||||
},
|
||||
'comic.s01.c15.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
|
||||
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
|
||||
},
|
||||
'comic.s01.c15.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
|
||||
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
|
||||
},
|
||||
'comic.s01.c15.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
|
||||
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
|
||||
},
|
||||
'comic.s01.c15.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
|
||||
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
|
||||
},
|
||||
'comic.s01.c15.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
|
||||
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
|
||||
},
|
||||
'comic.s01.c15.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
|
||||
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
|
||||
},
|
||||
'comic.s01.c15.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
|
||||
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
|
||||
},
|
||||
'comic.s01.c15.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
|
||||
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
|
||||
},
|
||||
'character.female-cook': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/female-cook.jpg',
|
||||
remotePath: 'characters/female-cook.c56420b2c632.jpg',
|
||||
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
|
||||
},
|
||||
'character.lele': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lele.jpg',
|
||||
remotePath: 'characters/lele.807f5232b1e8.jpg',
|
||||
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
|
||||
},
|
||||
'character.lin-xiulan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lin-xiulan.jpg',
|
||||
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
|
||||
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
|
||||
},
|
||||
'character.qin-xiaoman': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-xiaoman.jpg',
|
||||
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
|
||||
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
|
||||
},
|
||||
'character.qin-zhicheng': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-zhicheng.jpg',
|
||||
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
|
||||
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
|
||||
},
|
||||
'character.tang-mingyuan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-mingyuan.jpg',
|
||||
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
|
||||
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
|
||||
},
|
||||
'character.tang-shouan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-shouan.jpg',
|
||||
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
|
||||
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
|
||||
},
|
||||
'character.xiaozhen': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/xiaozhen.jpg',
|
||||
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
|
||||
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
|
||||
},
|
||||
'character.zhao-jianguo': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/zhao-jianguo.jpg',
|
||||
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
|
||||
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
|
||||
},
|
||||
'scene.gui-xiang.1978': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
|
||||
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
|
||||
},
|
||||
'scene.gui-xiang.1995': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
|
||||
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
|
||||
},
|
||||
'scene.gui-xiang.1998': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
|
||||
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
|
||||
},
|
||||
'scene.gui-xiang.2001': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
|
||||
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
|
||||
},
|
||||
'scene.gui-xiang.2003': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
|
||||
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
|
||||
},
|
||||
'scene.gui-xiang.2008': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
|
||||
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
|
||||
},
|
||||
'scene.gui-xiang.2026': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
|
||||
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
|
||||
},
|
||||
'audio.cast-audition.v1': {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'audition',
|
||||
localSeed: '',
|
||||
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
|
||||
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
|
||||
// The reader no longer maps them as page narration; C01 uses full-page tracks
|
||||
// through the two package-local audio player pages instead.
|
||||
for (const cueId of [
|
||||
'S01-C01-MS007','S01-C01-MS010',
|
||||
]) {
|
||||
const file = `${cueId}.mp3`
|
||||
const sha256 = {
|
||||
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
|
||||
}[cueId]
|
||||
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
|
||||
}
|
||||
|
||||
const audioC02Sha256 = {
|
||||
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
|
||||
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
|
||||
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
|
||||
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
|
||||
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
|
||||
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
|
||||
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
|
||||
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
|
||||
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
|
||||
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
|
||||
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
|
||||
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
|
||||
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
|
||||
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
|
||||
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
|
||||
}
|
||||
|
||||
const audioC03Sha256 = {
|
||||
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
|
||||
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
|
||||
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
|
||||
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
|
||||
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
|
||||
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
|
||||
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
|
||||
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
|
||||
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
|
||||
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
|
||||
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
|
||||
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
|
||||
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
|
||||
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
|
||||
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
|
||||
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
|
||||
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
|
||||
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
|
||||
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
|
||||
}
|
||||
|
||||
// C02/C03 cue files are retained only as rollback/source material. A single
|
||||
// cue is not a complete page track, so these assets must not pass the runtime
|
||||
// audio gate or appear as “试听本页”.
|
||||
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
|
||||
for (const [cueId, sha256] of Object.entries(audioHashes)) {
|
||||
const file = `${cueId}.mp3`
|
||||
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'reserved',
|
||||
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
|
||||
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RELEASE_ASSET_MANIFEST_VERSION,
|
||||
releaseAssets,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* C02-C15 remote full-page audio manifest v1.
|
||||
*
|
||||
* Import contract for a future reviewed batch:
|
||||
*
|
||||
* 'S01-C09-P05': Object.freeze({
|
||||
* pageId: 'S01-C09-P05',
|
||||
* chapterNumber: 9,
|
||||
* pageNumber: 5,
|
||||
* assetId: 'audio.page.s01.c09.p05',
|
||||
* durationSeconds: 42.1,
|
||||
* reviewStatus: 'approved',
|
||||
* })
|
||||
*
|
||||
* The matching releaseAssetManifest entry must independently be an approved,
|
||||
* remote-only, immutable hash-named audio asset. Automated QA, pending human
|
||||
* listening, and missing CDN records must never be relabelled as approved.
|
||||
*/
|
||||
|
||||
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
|
||||
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
// Deliberately empty until full-page tracks pass the required human reviews
|
||||
// and their immutable CDN objects are available. This is a release gate, not
|
||||
// a placeholder that the UI may treat as playable.
|
||||
const remotePageAudioPages = Object.freeze({})
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function remotePageAudioAssetId(pageId) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return ''
|
||||
return `audio.page.s01.c${match[1]}.p${match[2]}`
|
||||
}
|
||||
|
||||
function getApprovedRemotePageAudio(
|
||||
pageId,
|
||||
releaseAssets = {},
|
||||
pageManifest = remotePageAudioPages,
|
||||
) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return null
|
||||
|
||||
const entry = pageManifest && pageManifest[stablePageId]
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const chapterNumber = Number(match[1])
|
||||
const pageNumber = Number(match[2])
|
||||
const assetId = remotePageAudioAssetId(stablePageId)
|
||||
const durationSeconds = Number(entry.durationSeconds)
|
||||
if (
|
||||
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| entry.pageId !== stablePageId
|
||||
|| Number(entry.chapterNumber) !== chapterNumber
|
||||
|| Number(entry.pageNumber) !== pageNumber
|
||||
|| entry.assetId !== assetId
|
||||
|| !Number.isFinite(durationSeconds)
|
||||
|| durationSeconds <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const asset = releaseAssets && releaseAssets[assetId]
|
||||
const sha256 = asset && typeof asset.sha256 === 'string'
|
||||
? asset.sha256
|
||||
: ''
|
||||
const remotePath = asset && asset.remotePath
|
||||
const expectedRemotePath = (
|
||||
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
|
||||
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
|
||||
)
|
||||
if (
|
||||
!asset
|
||||
|| asset.kind !== 'audio'
|
||||
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| asset.localSeed !== ''
|
||||
|| !SHA256_PATTERN.test(sha256)
|
||||
|| typeof remotePath !== 'string'
|
||||
|| remotePath !== expectedRemotePath
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...entry, assetId }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REMOTE_PAGE_AUDIO_ID_PATTERN,
|
||||
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
|
||||
getApprovedRemotePageAudio,
|
||||
remotePageAudioAssetId,
|
||||
remotePageAudioPages,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "唐侦探",
|
||||
"pageOrientation": "landscape",
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
<view
|
||||
wx:if="{{chapter}}"
|
||||
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
|
||||
style="{{pageStyle}}"
|
||||
>
|
||||
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
|
||||
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
|
||||
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录">‹ 目录</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
|
||||
class="comic-top-previous-button"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一张画"
|
||||
>‹</button>
|
||||
<view class="comic-bookmark">
|
||||
<text class="comic-bookmark-label">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
|
||||
</text>
|
||||
<text class="comic-page-count">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
</view>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
|
||||
bindtap="openTextReading"
|
||||
aria-label="本页可以直接看文字"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text class="comic-page-audio-label">直接看文字</text>
|
||||
<text class="comic-page-audio-meta">按页慢慢看</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
|
||||
disabled="{{audioLoading}}"
|
||||
bindtap="toggleComicPageAudio"
|
||||
data-audio-cue-count="{{currentPage.audioCueCount}}"
|
||||
aria-label="本页语音播放控制"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">
|
||||
{{audioPlaying ? 'Ⅱ' : '▶'}}
|
||||
</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
|
||||
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
|
||||
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
|
||||
<text wx:else class="comic-page-audio-label">听一听</text>
|
||||
<text class="comic-page-audio-meta">
|
||||
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
|
||||
</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
|
||||
class="comic-font-button"
|
||||
bindtap="toggleFont"
|
||||
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
|
||||
>
|
||||
{{fontScale === 'large' ? '大字' : '标准字'}}
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
|
||||
class="c1-front-action-button"
|
||||
bindtap="openCurrentFrontAction"
|
||||
aria-label="{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
|
||||
: '点一下,翻到背面'}}"
|
||||
>
|
||||
{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '再看背面' : '点点人物')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '看看章末' : '听听心里话')
|
||||
: '翻到背面'}}
|
||||
</button>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view class="comic-reader">
|
||||
<block wx:if="{{storyFirstComicMode}}">
|
||||
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
|
||||
<block wx:if="{{chapterOneBackOpen}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">
|
||||
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边回声</text>
|
||||
<text>{{currentPage.memoryCard.tableEcho}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">带回生活里</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
<view class="c1-memory-secondary-actions">
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>看看生活观察</button>
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>发给家人聊聊</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{chapterOneReviewEvent.evidence}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边一句话</text>
|
||||
<text>“{{currentPage.shortDialogue}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text
|
||||
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
|
||||
class="c1-scroll-hint"
|
||||
>上划接着看 ↑</text>
|
||||
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
|
||||
</view>
|
||||
<view wx:else class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button
|
||||
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
|
||||
class="c1-back-button primary"
|
||||
bindtap="startChapterOneDecisionFromBack"
|
||||
>聊聊这事</button>
|
||||
<button
|
||||
wx:else
|
||||
class="c1-back-button primary"
|
||||
bindtap="nextChapterOnePageFromBack"
|
||||
>翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边回声</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">没有标准答案</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{emotionResult.feedback}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">这一桌记住了</text>
|
||||
<text>{{emotionResult.echo}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
|
||||
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
|
||||
<view class="c1-interaction-paper c1-emotion-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边一句话</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
|
||||
<text class="c1-tray-question">{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="c1-natural-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>{{item.text}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view class="c1-front-stage">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-front-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="c1-front-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="c1-front-grain"></view>
|
||||
|
||||
<button
|
||||
wx:if="{{!frontControlsRevealed}}"
|
||||
class="c1-front-reveal-gate"
|
||||
bindtap="revealCurrentFrontControls"
|
||||
aria-label="轻点画面,显示阅读按钮"
|
||||
></button>
|
||||
|
||||
<block wx:else>
|
||||
<button
|
||||
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
|
||||
class="c1-person-hotspot"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
|
||||
><view class="c1-hotspot-ring"></view></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="翻到这一幕的背面"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneEmotion"
|
||||
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:else
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="轻触画面,翻到这一页背面"
|
||||
></button>
|
||||
</block>
|
||||
|
||||
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
|
||||
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion'}}">
|
||||
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="special-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="special-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="emotion-panel">
|
||||
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="emotion-context">
|
||||
<text class="emotion-context-kicker">听听这一桌心里话</text>
|
||||
<text class="emotion-context-title">{{currentPage.headline}}</text>
|
||||
<text class="emotion-context-caption">{{currentPage.caption}}</text>
|
||||
</view>
|
||||
<text class="emotion-prompt">{{currentPage.prompt}}</text>
|
||||
<text
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-note"
|
||||
>没有标准答案,选您更愿意说的话。</text>
|
||||
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
|
||||
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
|
||||
<view class="table-echo">
|
||||
<text class="echo-label">桌边回声</text>
|
||||
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-choice-dock"
|
||||
>
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="emotion-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>
|
||||
<text class="choice-number">{{index + 1}}</text>
|
||||
<text>{{item.text}}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
|
||||
<button
|
||||
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{emotionResult || chapterFinished}}"
|
||||
class="special-page-button primary"
|
||||
bindtap="finishComicEmotion"
|
||||
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
|
||||
>
|
||||
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.isSeasonClose}}">
|
||||
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="season-close-body">
|
||||
<view class="season-close-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="season-close-main-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="season-close-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view
|
||||
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
|
||||
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
|
||||
aria-label="{{currentPage.detailInset.label}}近景"
|
||||
>
|
||||
<image
|
||||
class="season-close-detail-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{currentPage.detailInset.imageMode}}"
|
||||
style="{{currentPage.detailInset.imageStyle}}"
|
||||
></image>
|
||||
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="season-close-copy"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="season-close-copy-sheet">
|
||||
<text class="season-close-kicker">第一季 · 季终</text>
|
||||
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
|
||||
<view class="season-close-memory-card">
|
||||
<text class="season-close-card-label">桂香记忆卡</text>
|
||||
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
|
||||
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="season-close-life-line">
|
||||
<text class="season-close-line-label">今天可以这样做</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<view class="season-close-family-line">
|
||||
<text class="season-close-line-label">带回家聊一聊</text>
|
||||
<text>“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="memory-actions season-close-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="第一季读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'memory'}}">
|
||||
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
|
||||
<view class="memory-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="memory-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="memory-panel">
|
||||
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
|
||||
<view class="memory-paper-content">
|
||||
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
|
||||
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
|
||||
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
|
||||
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
|
||||
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
|
||||
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
|
||||
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
|
||||
</button>
|
||||
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="memory-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{chapterNumber >= chapterCount}}"
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="全书读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
<button
|
||||
wx:else
|
||||
class="memory-action-cell primary"
|
||||
bindtap="nextChapter"
|
||||
aria-label="翻到下一回"
|
||||
>
|
||||
<text class="memory-action-visible">翻到下一回</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view
|
||||
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
|
||||
bindtouchstart="onComicTouchStart"
|
||||
bindtouchend="onComicTouchEnd"
|
||||
>
|
||||
<view class="comic-art-column">
|
||||
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{comicPageImageMode}}"
|
||||
style="{{comicPageImageStyle}}"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="comic-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
<view
|
||||
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
|
||||
class="comic-evidence-composite"
|
||||
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
|
||||
>
|
||||
<text class="comic-evidence-kicker">手边证据</text>
|
||||
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
|
||||
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
|
||||
</view>
|
||||
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
|
||||
class="comic-actor-hotspot is-quiet"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
|
||||
></button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
|
||||
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
|
||||
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
|
||||
<view class="comic-cover-caption-heading">
|
||||
<text class="comic-caption-kicker">{{chapter.year}}</text>
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
</view>
|
||||
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
<view class="comic-ensemble-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
|
||||
<view class="comic-event-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
<view class="comic-event-cue">
|
||||
<text
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight}}"
|
||||
class="comic-outside-actor-button"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
|
||||
<text class="seen-chip">看见了</text>
|
||||
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
wx:if="{{currentInteractionEnabled && compactHeight}}"
|
||||
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
|
||||
>
|
||||
<text
|
||||
wx:if="{{currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
class="comic-outside-actor-button comic-outside-actor-dock"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="comic-page-nav">
|
||||
<button
|
||||
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
<button
|
||||
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="nextComicPage"
|
||||
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
|
||||
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">
|
||||
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
|
||||
</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
|
||||
<view
|
||||
class="text-reading-dialog paper-panel"
|
||||
aria-role="dialog"
|
||||
aria-label="本页大字文字"
|
||||
>
|
||||
<button
|
||||
class="dialog-close"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="关闭文字阅读"
|
||||
>×</button>
|
||||
<text class="text-reading-kicker">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
|
||||
</text>
|
||||
<scroll-view
|
||||
class="text-reading-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="text-reading-sheet">
|
||||
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
|
||||
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">这一页想和您聊</text>
|
||||
<text>{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">看看画里发生了什么</text>
|
||||
<text>{{currentPage.question}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<button
|
||||
class="text-reading-return"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="回到连环画"
|
||||
>回到画里</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
|
||||
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">画里的线索</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
|
||||
<text class="c1-tray-question">{{activeEvent.question}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{activeEvent.choiceOptions}}"
|
||||
wx:key="value"
|
||||
class="c1-natural-choice"
|
||||
data-value="{{item.value}}"
|
||||
bindtap="answer"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">再看看画里</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel c1-retry-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
|
||||
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
|
||||
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
|
||||
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-paper c1-event-result-back">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">这下有眉目了</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
|
||||
<text class="c1-back-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<block wx:else>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
<text class="c1-back-reason">{{activeEvent.reason}}</text>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text class="c1-scroll-hint">上划接着看 ↑</text>
|
||||
<view class="c1-back-footer">
|
||||
<button
|
||||
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="c1-back-button secondary"
|
||||
aria-label="回头再看一遍画面"
|
||||
bindtap="returnFromChapterOneDecision"
|
||||
>回去看画</button>
|
||||
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
|
||||
<view
|
||||
class="decision-dialog paper-panel comic-decision"
|
||||
aria-role="dialog"
|
||||
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
|
||||
>
|
||||
<button
|
||||
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="dialog-close"
|
||||
bindtap="closeDecision"
|
||||
aria-label="回到画里"
|
||||
>×</button>
|
||||
|
||||
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="character-story-inner">
|
||||
<view class="comic-focus-visual">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-focus-scene"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
style="{{comicFocusImageStyle}}"
|
||||
></image>
|
||||
<view class="comic-focus-shade"></view>
|
||||
<view class="comic-focus-name">
|
||||
<text>{{activePerson.eraLabel}} · 画中人物</text>
|
||||
<text class="focus-name">{{activeEvent.actorName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="story-label">这一幕发生了什么</text>
|
||||
<text class="action-description">{{activeEvent.actionDescription}}</text>
|
||||
|
||||
<view class="evidence-box">
|
||||
<text class="story-label">手边证据</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
|
||||
<view class="speech-box">
|
||||
<text class="story-label">桌边说话</text>
|
||||
<text>{{activeEvent.speech}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
|
||||
<scroll-view
|
||||
class="judgement-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{decisionResultNeedsScroll}}"
|
||||
>
|
||||
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
|
||||
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
|
||||
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
|
||||
|
||||
<view wx:if="{{resultMode === ''}}" class="judgement-options">
|
||||
<button class="judgement-button observe" data-value="caution" bindtap="answer">
|
||||
<text class="judgement-icon">A</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">先看看现场细节</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="judgement-button continue" data-value="safer" bindtap="answer">
|
||||
<text class="judgement-icon">B</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">继续按画里的做法</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
|
||||
<text class="result-title">没关系,再看看画里的细节。</text>
|
||||
<text>{{activeEvent.retryHint}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
|
||||
<text class="result-title">这处线索对上了。</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
|
||||
<text class="advice-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<view wx:else class="action-advice">
|
||||
<text class="advice-label">现在可以怎么做</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
|
||||
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
|
||||
</view>
|
||||
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="openResultDetail">
|
||||
再看不同情况怎么办
|
||||
</button>
|
||||
</view>
|
||||
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
|
||||
回到画里
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
|
||||
contractId: 'story-first-three-frame-v1',
|
||||
columns: Object.freeze({
|
||||
art: 3,
|
||||
content: 2,
|
||||
}),
|
||||
frames: Object.freeze({
|
||||
front: Object.freeze({
|
||||
imageMode: 'aspectFit',
|
||||
initialControls: 'none',
|
||||
revealGesture: 'single-tap',
|
||||
revealedControls: 'topbar-only',
|
||||
}),
|
||||
back: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
}),
|
||||
interaction: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
choiceCount: 2,
|
||||
choiceDirection: 'vertical',
|
||||
}),
|
||||
}),
|
||||
minimumControlPx: 48,
|
||||
compactHeightMaxPx: 620,
|
||||
fontModes: Object.freeze({
|
||||
standard: 'large',
|
||||
xlarge: 'xlarge',
|
||||
}),
|
||||
audioSlot: Object.freeze({
|
||||
placement: 'unified-topbar',
|
||||
minimumHeightPx: 48,
|
||||
textModeLabel: '直接看文字',
|
||||
readyLabel: '听一听',
|
||||
playingLabel: '暂停',
|
||||
replayLabel: '重听',
|
||||
usesPageAudioCueIds: true,
|
||||
}),
|
||||
artOverlayPolicy: Object.freeze({
|
||||
event: 'transparent-actor-hotspot-only',
|
||||
emotion: 'transparent-actor-hotspot-only',
|
||||
}),
|
||||
})
|
||||
|
||||
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
|
||||
cover: 'chapter-ensemble',
|
||||
ensemble: 'chapter-ensemble',
|
||||
event: 'actor-event',
|
||||
emotion: 'emotion-interaction',
|
||||
memory: 'memory-cliffhanger',
|
||||
})
|
||||
|
||||
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
|
||||
'S01-C01': 'front-back-comic-v1',
|
||||
'S01-C02': 'front-back-comic-v1',
|
||||
'S01-C03': 'front-back-comic-v1',
|
||||
'S01-C04': 'front-back-comic-v1',
|
||||
'S01-C05': 'front-back-comic-v1',
|
||||
'S01-C06': 'front-back-comic-v1',
|
||||
'S01-C07': 'front-back-comic-v1',
|
||||
'S01-C08': 'front-back-comic-v1',
|
||||
'S01-C09': 'front-back-comic-v1',
|
||||
'S01-C10': 'front-back-comic-v1',
|
||||
'S01-C11': 'front-back-comic-v1',
|
||||
'S01-C12': 'front-back-comic-v1',
|
||||
'S01-C13': 'front-back-comic-v1',
|
||||
'S01-C14': 'front-back-comic-v1',
|
||||
'S01-C15': 'front-back-comic-v1',
|
||||
})
|
||||
|
||||
function getComicPresentationMode(chapterId) {
|
||||
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|
||||
|| 'legacy-60-40-v1'
|
||||
}
|
||||
|
||||
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
|
||||
'S01-C09-P03': Object.freeze({
|
||||
layoutVariant: 'standard-landscape-60-40',
|
||||
behavior: 'key-safe-actor-hotspot',
|
||||
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
|
||||
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
|
||||
}),
|
||||
})
|
||||
|
||||
// These pages keep the full reviewed frame and add one read-only crop from
|
||||
// the same asset as a second comic panel. This is an art-reading aid, not a
|
||||
// new interaction or a page-layout exception.
|
||||
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
|
||||
'S01-C04-P04',
|
||||
'S01-C09-P01',
|
||||
'S01-C09-P05',
|
||||
'S01-C09-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C13-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function allowsFixedDetailInset(pageId) {
|
||||
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
|
||||
String(pageId || ''),
|
||||
) >= 0
|
||||
}
|
||||
|
||||
function getComicPageLayoutException(pageId) {
|
||||
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
|
||||
}
|
||||
|
||||
function resolveComicPageLayout(pageId, type) {
|
||||
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
|
||||
const exception = getComicPageLayoutException(pageId)
|
||||
return {
|
||||
templateKey,
|
||||
layoutVariant: exception
|
||||
? exception.layoutVariant
|
||||
: 'standard-landscape-60-40',
|
||||
layoutExceptionId: exception ? String(pageId) : '',
|
||||
layoutExceptionBehavior: exception ? exception.behavior : '',
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_READER_LAYOUT_CONTRACT,
|
||||
COMIC_PRESENTATION_BY_CHAPTER,
|
||||
COMIC_PAGE_TEMPLATE_BY_TYPE,
|
||||
COMIC_PAGE_LAYOUT_EXCEPTIONS,
|
||||
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
|
||||
allowsFixedDetailInset,
|
||||
getComicPresentationMode,
|
||||
getComicPageLayoutException,
|
||||
resolveComicPageLayout,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
const CACHE_INDEX_VERSION = 2
|
||||
const CACHE_FOLDER = 'tang-detective-assets-v2'
|
||||
const CACHE_INDEX_FILE = 'index.json'
|
||||
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_MAX_ENTRIES = 32
|
||||
|
||||
const SHA256_ROUND_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function utf8Bytes(value) {
|
||||
const encoded = encodeURIComponent(String(value || ''))
|
||||
const bytes = []
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] === '%') {
|
||||
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
|
||||
index += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
if (typeof value === 'string') return utf8Bytes(value)
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
throw new Error('unsupported SHA-256 input')
|
||||
}
|
||||
|
||||
function rotateRight(value, bits) {
|
||||
return (value >>> bits) | (value << (32 - bits))
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
const bytes = toBytes(value)
|
||||
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||
const message = new Uint8Array(paddedLength)
|
||||
message.set(bytes)
|
||||
message[bytes.length] = 0x80
|
||||
const bitLength = bytes.length * 8
|
||||
const highBits = Math.floor(bitLength / 0x100000000)
|
||||
const lowBits = bitLength >>> 0
|
||||
const lengthOffset = paddedLength - 8
|
||||
message[lengthOffset] = (highBits >>> 24) & 0xff
|
||||
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
|
||||
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
|
||||
message[lengthOffset + 3] = highBits & 0xff
|
||||
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
|
||||
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
|
||||
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
|
||||
message[lengthOffset + 7] = lowBits & 0xff
|
||||
|
||||
const hash = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]
|
||||
const words = new Uint32Array(64)
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const start = offset + index * 4
|
||||
words[index] = (
|
||||
(message[start] << 24)
|
||||
| (message[start + 1] << 16)
|
||||
| (message[start + 2] << 8)
|
||||
| message[start + 3]
|
||||
) >>> 0
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const word15 = words[index - 15]
|
||||
const word2 = words[index - 2]
|
||||
const sigma0 = (
|
||||
rotateRight(word15, 7)
|
||||
^ rotateRight(word15, 18)
|
||||
^ (word15 >>> 3)
|
||||
)
|
||||
const sigma1 = (
|
||||
rotateRight(word2, 17)
|
||||
^ rotateRight(word2, 19)
|
||||
^ (word2 >>> 10)
|
||||
)
|
||||
words[index] = (
|
||||
words[index - 16]
|
||||
+ sigma0
|
||||
+ words[index - 7]
|
||||
+ sigma1
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
let a = hash[0]
|
||||
let b = hash[1]
|
||||
let c = hash[2]
|
||||
let d = hash[3]
|
||||
let e = hash[4]
|
||||
let f = hash[5]
|
||||
let g = hash[6]
|
||||
let h = hash[7]
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
||||
const choice = (e & f) ^ (~e & g)
|
||||
const temp1 = (
|
||||
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
|
||||
) >>> 0
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (sum0 + majority) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0
|
||||
hash[1] = (hash[1] + b) >>> 0
|
||||
hash[2] = (hash[2] + c) >>> 0
|
||||
hash[3] = (hash[3] + d) >>> 0
|
||||
hash[4] = (hash[4] + e) >>> 0
|
||||
hash[5] = (hash[5] + f) >>> 0
|
||||
hash[6] = (hash[6] + g) >>> 0
|
||||
hash[7] = (hash[7] + h) >>> 0
|
||||
}
|
||||
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||
}
|
||||
|
||||
function constantTimeEqualHex(left, right) {
|
||||
const leftValue = String(left || '').toLowerCase()
|
||||
const rightValue = String(right || '').toLowerCase()
|
||||
const length = Math.max(leftValue.length, rightValue.length)
|
||||
let difference = leftValue.length ^ rightValue.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
|
||||
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
|
||||
difference |= leftCode ^ rightCode
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function joinPath(left, right) {
|
||||
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function normalizeIndex(value) {
|
||||
if (
|
||||
!value
|
||||
|| value.version !== CACHE_INDEX_VERSION
|
||||
|| !value.entries
|
||||
|| typeof value.entries !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: CACHE_INDEX_VERSION,
|
||||
entries: {},
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function callFs(fs, method, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs || typeof fs[method] !== 'function') {
|
||||
reject(new Error(`fs.${method} unavailable`))
|
||||
return
|
||||
}
|
||||
fs[method]({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createAssetPlatformFacade() {
|
||||
if (typeof wx === 'undefined') return null
|
||||
const env = Object.freeze({
|
||||
USER_DATA_PATH: String(
|
||||
wx.env && wx.env.USER_DATA_PATH || '',
|
||||
),
|
||||
})
|
||||
return Object.freeze({
|
||||
downloadFile(options) {
|
||||
if (typeof wx.downloadFile !== 'function') {
|
||||
if (options && typeof options.fail === 'function') {
|
||||
options.fail(new Error('wx.downloadFile unavailable'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return wx.downloadFile(options)
|
||||
},
|
||||
getFileSystemManager() {
|
||||
if (typeof wx.getFileSystemManager !== 'function') return null
|
||||
return wx.getFileSystemManager()
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
function download(assetPlatform, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
|
||||
reject(new Error('wx.downloadFile unavailable'))
|
||||
return
|
||||
}
|
||||
assetPlatform.downloadFile({
|
||||
url,
|
||||
success(result) {
|
||||
if (
|
||||
result
|
||||
&& result.statusCode >= 200
|
||||
&& result.statusCode < 300
|
||||
&& result.tempFilePath
|
||||
) {
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
reject(new Error(`download status ${result && result.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function safeCacheName(assetId, asset) {
|
||||
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
|
||||
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
|
||||
}
|
||||
|
||||
function createAssetManager(options = {}) {
|
||||
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
|
||||
const manifest = options.manifest || {}
|
||||
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
|
||||
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
|
||||
? Math.max(0, options.imageCacheBudgetBytes)
|
||||
: DEFAULT_IMAGE_BUDGET
|
||||
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
|
||||
? Math.max(0, options.audioCacheBudgetBytes)
|
||||
: DEFAULT_AUDIO_BUDGET
|
||||
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
|
||||
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
|
||||
: DEFAULT_AUDIO_MAX_ENTRIES
|
||||
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
|
||||
? Math.max(1, Math.floor(options.downloadConcurrency))
|
||||
: 2
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now
|
||||
const fs = assetPlatform
|
||||
&& typeof assetPlatform.getFileSystemManager === 'function'
|
||||
? assetPlatform.getFileSystemManager()
|
||||
: null
|
||||
const userDataPath = (
|
||||
assetPlatform
|
||||
&& assetPlatform.env
|
||||
&& assetPlatform.env.USER_DATA_PATH
|
||||
) || ''
|
||||
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
|
||||
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
|
||||
|
||||
let initialized = false
|
||||
let index = normalizeIndex(null)
|
||||
let activeDownloads = 0
|
||||
const inflight = new Map()
|
||||
const downloadWaiters = []
|
||||
|
||||
function fallback(assetId, reason) {
|
||||
return {
|
||||
assetId,
|
||||
available: false,
|
||||
uri: '',
|
||||
source: 'text-fallback',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCacheFolder() {
|
||||
if (!cacheRoot) return false
|
||||
try {
|
||||
await callFs(fs, 'mkdir', {
|
||||
dirPath: cacheRoot,
|
||||
recursive: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// EEXIST and older base-library mkdir failures are both safe to ignore.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function persistIndex() {
|
||||
if (!indexPath) return
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
await callFs(fs, 'writeFile', {
|
||||
filePath: indexPath,
|
||||
data: JSON.stringify(index),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
} catch (error) {
|
||||
// Cache metadata must never block the text-first game.
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
await callFs(fs, 'access', { path: filePath })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMatchesSha256(filePath, expectedSha256) {
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', { filePath })
|
||||
return constantTimeEqualHex(
|
||||
sha256Hex(result.data),
|
||||
expectedSha256,
|
||||
)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (!indexPath) return
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', {
|
||||
filePath: indexPath,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
index = normalizeIndex(JSON.parse(result.data))
|
||||
} catch (error) {
|
||||
index = normalizeIndex(null)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [assetId, entry] of Object.entries(index.entries)) {
|
||||
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await persistIndex()
|
||||
await enforceImageBudget()
|
||||
await enforceAudioBudget()
|
||||
}
|
||||
|
||||
function cacheBytes(kind) {
|
||||
return Object.values(index.entries).reduce(
|
||||
(total, entry) => (
|
||||
entry.kind === kind
|
||||
? total + Math.max(0, Number(entry.size) || 0)
|
||||
: total
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function cacheEntries(kind) {
|
||||
return Object.values(index.entries)
|
||||
.filter((entry) => entry.kind === kind)
|
||||
.length
|
||||
}
|
||||
|
||||
function imageCacheBytes() {
|
||||
return cacheBytes('image')
|
||||
}
|
||||
|
||||
function audioCacheBytes() {
|
||||
return cacheBytes('audio')
|
||||
}
|
||||
|
||||
async function unlinkQuietly(filePath) {
|
||||
try {
|
||||
await callFs(fs, 'unlink', { filePath })
|
||||
} catch (error) {
|
||||
// A missing cache file is already evicted.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceImageBudget(protectedAssetId = '') {
|
||||
let total = imageCacheBytes()
|
||||
if (total <= imageBudgetBytes) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'image' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= imageBudgetBytes) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function enforceAudioBudget(protectedAssetId = '') {
|
||||
let total = audioCacheBytes()
|
||||
let entries = cacheEntries('audio')
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'audio' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
entries -= 1
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function resolveCached(assetId, asset) {
|
||||
const entry = index.entries[assetId]
|
||||
if (!entry || entry.sha256 !== asset.sha256) return null
|
||||
if (!(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return null
|
||||
}
|
||||
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
|
||||
await unlinkQuietly(entry.filePath)
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
entry.lastAccessedAt = now()
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: entry.filePath,
|
||||
source: 'cache',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTempFileSize(tempFilePath, response) {
|
||||
if (Number.isFinite(response.fileSize)) return response.fileSize
|
||||
try {
|
||||
const statResult = await callFs(fs, 'stat', {
|
||||
path: tempFilePath,
|
||||
})
|
||||
const stat = statResult && statResult.stats
|
||||
return Math.max(0, Number(stat && stat.size) || 0)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(assetId, asset) {
|
||||
if (!cdnBaseUrl || !asset.remotePath) {
|
||||
return fallback(assetId, 'remote-disabled')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
activeDownloads += 1
|
||||
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1
|
||||
const next = downloadWaiters.shift()
|
||||
if (next) next()
|
||||
})
|
||||
}
|
||||
if (activeDownloads < downloadConcurrency) run()
|
||||
else downloadWaiters.push(run)
|
||||
})
|
||||
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
|
||||
await unlinkQuietly(response.tempFilePath)
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
const size = await getTempFileSize(response.tempFilePath, response)
|
||||
|
||||
const exceedsCachePolicy = (
|
||||
(asset.kind === 'image' && size > imageBudgetBytes)
|
||||
|| (
|
||||
asset.kind === 'audio'
|
||||
&& (size > audioBudgetBytes || audioMaxEntries < 1)
|
||||
)
|
||||
)
|
||||
if (!cacheRoot || exceedsCachePolicy) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
|
||||
await callFs(fs, 'saveFile', {
|
||||
tempFilePath: response.tempFilePath,
|
||||
filePath,
|
||||
})
|
||||
index.entries[assetId] = {
|
||||
assetId,
|
||||
filePath,
|
||||
kind: asset.kind,
|
||||
sha256: asset.sha256,
|
||||
size,
|
||||
lastAccessedAt: now(),
|
||||
}
|
||||
await enforceImageBudget(assetId)
|
||||
await enforceAudioBudget(assetId)
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: filePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(assetId, resolveOptions = {}) {
|
||||
await init()
|
||||
const asset = manifest[assetId]
|
||||
if (!asset) return fallback(assetId, 'unknown-asset')
|
||||
if (
|
||||
asset.kind === 'audio'
|
||||
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
|
||||
) {
|
||||
return fallback(assetId, 'audio-unapproved')
|
||||
}
|
||||
|
||||
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
|
||||
if (asset.localSeed && resolveOptions.preferRemote !== true) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
const cached = await resolveCached(assetId, asset)
|
||||
if (cached) return cached
|
||||
|
||||
if (resolveOptions.allowRemote === false) {
|
||||
if (asset.localSeed) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
return fallback(assetId, 'remote-disallowed')
|
||||
}
|
||||
|
||||
if (inflight.has(assetId)) return inflight.get(assetId)
|
||||
|
||||
const request = fetchRemote(assetId, asset)
|
||||
.catch(() => (
|
||||
asset.localSeed
|
||||
? {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
: fallback(assetId, 'remote-failed')
|
||||
))
|
||||
.finally(() => inflight.delete(assetId))
|
||||
inflight.set(assetId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function prefetch(assetIds) {
|
||||
const ids = Array.isArray(assetIds) ? assetIds : []
|
||||
return Promise.all(ids.map((assetId) => resolve(assetId)))
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await init()
|
||||
const entries = Object.values(index.entries)
|
||||
for (const entry of entries) await unlinkQuietly(entry.filePath)
|
||||
index = normalizeIndex(null)
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return {
|
||||
entries: Object.keys(index.entries).length,
|
||||
imageBytes: imageCacheBytes(),
|
||||
imageBudgetBytes,
|
||||
audioEntries: cacheEntries('audio'),
|
||||
audioBytes: audioCacheBytes(),
|
||||
audioBudgetBytes,
|
||||
audioMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
resolve,
|
||||
prefetch,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
getAsset(assetId) {
|
||||
return manifest[assetId] || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_INDEX_VERSION,
|
||||
DEFAULT_AUDIO_BUDGET,
|
||||
DEFAULT_AUDIO_MAX_ENTRIES,
|
||||
constantTimeEqualHex,
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
sha256Hex,
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
const REVIEWED_AUDIO_STATUS = 'approved'
|
||||
const REVIEWED_VISUAL_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_STATUS = 'experience-provisional'
|
||||
const PROVISIONAL_VISUAL_TIER = 'experience-provisional'
|
||||
const IMAGE_UNAVAILABLE_MESSAGE = '本页画面暂时没有打开,文字和互动仍可继续。'
|
||||
const AUDIO_FILE_PATTERN = /\.(?:aac|m4a|mp3|ogg|wav)$/i
|
||||
|
||||
function cleanPath(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isPlayableVisualDeclaration(visual) {
|
||||
if (!visual || typeof visual !== 'object') return false
|
||||
const reviewStatus = cleanPath(visual.reviewStatus).toLowerCase()
|
||||
if (reviewStatus === REVIEWED_VISUAL_STATUS) return true
|
||||
return Boolean(
|
||||
visual.kind === 'page-art'
|
||||
&& cleanPath(visual.runtimeTier) === PROVISIONAL_VISUAL_TIER
|
||||
&& visual.formalReleaseEligible === false
|
||||
&& reviewStatus === PROVISIONAL_VISUAL_STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
function isPackagedPath(value) {
|
||||
const path = cleanPath(value)
|
||||
return (
|
||||
path.startsWith('/assets/')
|
||||
|| /^\/package-[a-z0-9-]+\//i.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
function actorSheetFocusPercent(person) {
|
||||
const era = String(person && person.eraLabel || '')
|
||||
const characterId = String(person && person.characterId || '')
|
||||
if (characterId === 'qin-xiaoman') {
|
||||
if (/2008/.test(era)) return 16
|
||||
if (/2026/.test(era)) return 65
|
||||
}
|
||||
if (characterId === 'tang-mingyuan') {
|
||||
if (/1995/.test(era)) return 11
|
||||
if (/2001/.test(era)) return 37
|
||||
if (/2003/.test(era)) return 63
|
||||
if (/2026/.test(era)) return 88
|
||||
}
|
||||
if (/1978|1980/.test(era)) return 11
|
||||
if (/1993|1995|1998/.test(era)) return 31
|
||||
if (/2001|2003/.test(era)) return 50
|
||||
if (/2008/.test(era)) return 69
|
||||
if (/2026/.test(era)) return 88
|
||||
return 50
|
||||
}
|
||||
|
||||
function isSinglePortraitFallback(person) {
|
||||
return String(person && person.characterId || '') === 'female-cook'
|
||||
}
|
||||
|
||||
function getComicPageImageMode(person, usingActorFallback) {
|
||||
return usingActorFallback && isSinglePortraitFallback(person)
|
||||
? 'aspectFit'
|
||||
: 'scaleToFill'
|
||||
}
|
||||
|
||||
function getComicPageImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const focusX = actorSheetFocusPercent(person)
|
||||
const translateX = 50 - (3.5 * focusX)
|
||||
return [
|
||||
'transform-origin:0 0',
|
||||
`transform:translate(${translateX}%,-30%) scale(3.5)`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
function getComicFocusImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const sourceFocusX = actorSheetFocusPercent(person)
|
||||
const focusX = 25 + (sourceFocusX * 0.5)
|
||||
return [
|
||||
'transform:scale(7)',
|
||||
`transform-origin:${focusX}% 12%`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first image shown by a comic page.
|
||||
*
|
||||
* A release-manifest localSeed is authoritative and must never wait for the
|
||||
* network. A remote-only illustration starts on the reviewed scene fallback,
|
||||
* so a slow or failed download cannot leave a blank reader.
|
||||
*/
|
||||
function buildComicImageState(page = {}, releaseAsset = null, previous = {}) {
|
||||
const illustrationAsset = cleanPath(page.illustrationAsset)
|
||||
const fallbackAsset = cleanPath(page.fallbackAsset)
|
||||
const playableVisual = isPlayableVisualDeclaration(page.playableVisual)
|
||||
? page.playableVisual
|
||||
: null
|
||||
const sharedVisualKind = playableVisual
|
||||
&& playableVisual.kind !== 'page-art'
|
||||
? cleanPath(playableVisual.kind)
|
||||
: ''
|
||||
const sharedVisualAsset = sharedVisualKind
|
||||
? cleanPath(playableVisual.asset)
|
||||
: ''
|
||||
const actorFallbackAsset = cleanPath(
|
||||
(
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
? sharedVisualAsset
|
||||
: (page.actorFallbackAsset || page.actorPortrait),
|
||||
)
|
||||
const localSeed = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.localSeed)
|
||||
: ''
|
||||
const remotePath = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.remotePath)
|
||||
: ''
|
||||
const keepFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& fallbackAsset
|
||||
)
|
||||
const keepActorFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& previous.comicImageSource === 'actor-fallback'
|
||||
&& actorFallbackAsset
|
||||
)
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& (
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: sharedVisualAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'shared-portrait',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& sharedVisualKind === 'evidence-composite'
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'shared-evidence',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepActorFallback) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepFallback) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (localSeed) {
|
||||
return {
|
||||
src: localSeed,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'local-seed',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (remotePath) {
|
||||
const loadingFallback = actorFallbackAsset || fallbackAsset
|
||||
const usingActorFallback = Boolean(actorFallbackAsset)
|
||||
return {
|
||||
src: loadingFallback,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: Boolean(loadingFallback),
|
||||
usingActorFallback,
|
||||
source: usingActorFallback
|
||||
? 'actor-fallback-loading'
|
||||
: (fallbackAsset ? 'fallback-loading' : 'text-fallback-loading'),
|
||||
error: loadingFallback ? '' : IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseAsset && illustrationAsset) {
|
||||
return {
|
||||
src: illustrationAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'declared-local-path',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (actorFallbackAsset) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
src: '',
|
||||
fallback: '',
|
||||
actorFallback: '',
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'text-fallback',
|
||||
error: IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio is intentionally fail-closed. A non-empty src alone is not enough:
|
||||
* both the story data and immutable release manifest must mark the file as
|
||||
* approved, and the playable src must be the packaged, hash-checked seed.
|
||||
*/
|
||||
function getReviewedAudioSrc(media = {}, manifest = {}) {
|
||||
const status = cleanPath(media.status || media.audioStatus).toLowerCase()
|
||||
const assetId = cleanPath(media.assetId || media.audioAssetId)
|
||||
const declaredSrc = cleanPath(media.src || media.audioSrc)
|
||||
if (
|
||||
status !== REVIEWED_AUDIO_STATUS
|
||||
|| !assetId
|
||||
|| !declaredSrc
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const releaseAsset = manifest[assetId]
|
||||
if (
|
||||
!releaseAsset
|
||||
|| releaseAsset.kind !== 'audio'
|
||||
|| cleanPath(releaseAsset.reviewStatus).toLowerCase()
|
||||
!== REVIEWED_AUDIO_STATUS
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const localSeed = cleanPath(releaseAsset.localSeed)
|
||||
if (
|
||||
!localSeed
|
||||
|| localSeed !== declaredSrc
|
||||
|| !isPackagedPath(localSeed)
|
||||
|| !AUDIO_FILE_PATTERN.test(localSeed)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return localSeed
|
||||
}
|
||||
|
||||
function buildReviewedAudioState(media = {}, manifest = {}) {
|
||||
const src = getReviewedAudioSrc(media, manifest)
|
||||
return {
|
||||
available: Boolean(src),
|
||||
src,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDIO_FILE_PATTERN,
|
||||
IMAGE_UNAVAILABLE_MESSAGE,
|
||||
REVIEWED_AUDIO_STATUS,
|
||||
buildComicImageState,
|
||||
buildReviewedAudioState,
|
||||
getComicFocusImageStyle,
|
||||
getComicPageImageMode,
|
||||
getComicPageImageStyle,
|
||||
getReviewedAudioSrc,
|
||||
isPackagedPath,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
const COMIC_PAGE_TYPES = Object.freeze([
|
||||
'cover',
|
||||
'ensemble',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'emotion',
|
||||
'memory',
|
||||
])
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicPages(model = {}) {
|
||||
return Array.isArray(model.pageSequence) ? model.pageSequence : []
|
||||
}
|
||||
|
||||
function pageIndex(model, pageId) {
|
||||
const requested = cleanString(pageId)
|
||||
if (!requested) return -1
|
||||
return comicPages(model).findIndex((page) => page.pageId === requested)
|
||||
}
|
||||
|
||||
function eventPages(model) {
|
||||
return comicPages(model).filter((page) => page.type === 'event')
|
||||
}
|
||||
|
||||
function isEightPageComicModel(model = {}) {
|
||||
const pages = comicPages(model)
|
||||
if (
|
||||
model.mode !== 'comic'
|
||||
|| !cleanString(model.chapterId)
|
||||
|| pages.length !== COMIC_PAGE_TYPES.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return pages.every((page, index) => (
|
||||
page
|
||||
&& page.pageId === `${model.chapterId}-P${String(index + 1).padStart(2, '0')}`
|
||||
&& page.type === COMIC_PAGE_TYPES[index]
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a contiguous prefix of P03-P06 is trusted.
|
||||
*
|
||||
* This prevents stale, foreign, duplicated or out-of-order ids from opening a
|
||||
* later page. It also keeps the locked 60-event source order unchanged.
|
||||
*/
|
||||
function normalizeCompletedEventIds(model, completedIds = []) {
|
||||
const supplied = new Set(
|
||||
(Array.isArray(completedIds) ? completedIds : [])
|
||||
.map(cleanString)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const normalized = []
|
||||
for (const page of eventPages(model)) {
|
||||
if (!supplied.has(page.eventId)) break
|
||||
normalized.push(page.eventId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getUnlockedPageIndex(
|
||||
model,
|
||||
completedIds = [],
|
||||
chapterFinished = false,
|
||||
) {
|
||||
const pages = comicPages(model)
|
||||
if (!pages.length) return -1
|
||||
if (!isEightPageComicModel(model)) return 0
|
||||
if (chapterFinished) return pages.length - 1
|
||||
|
||||
const completed = normalizeCompletedEventIds(model, completedIds)
|
||||
const firstEventIndex = pages.findIndex((page) => page.type === 'event')
|
||||
const emotionIndex = pages.findIndex((page) => page.type === 'emotion')
|
||||
if (firstEventIndex < 0) return 0
|
||||
|
||||
return Math.min(
|
||||
emotionIndex >= 0 ? emotionIndex : pages.length - 1,
|
||||
firstEventIndex + completed.length,
|
||||
)
|
||||
}
|
||||
|
||||
function emptyReaderState() {
|
||||
return {
|
||||
valid: false,
|
||||
currentPage: null,
|
||||
currentPageId: '',
|
||||
currentPageIndex: -1,
|
||||
unlockedPageIndex: -1,
|
||||
unlockedPageIds: [],
|
||||
completedEventIds: [],
|
||||
allEventsComplete: false,
|
||||
chapterFinished: false,
|
||||
canGoPrevious: false,
|
||||
canGoNext: false,
|
||||
nextPageLocked: false,
|
||||
activeInteraction: null,
|
||||
activeHotspots: [],
|
||||
pageAccess: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildComicReaderState(model, progress = {}) {
|
||||
if (!isEightPageComicModel(model)) return emptyReaderState()
|
||||
|
||||
const pages = comicPages(model)
|
||||
const completedEventIds = normalizeCompletedEventIds(
|
||||
model,
|
||||
progress.completedEventIds || progress.completedIds,
|
||||
)
|
||||
const allEventsComplete = (
|
||||
completedEventIds.length === eventPages(model).length
|
||||
)
|
||||
const chapterFinished = Boolean(
|
||||
progress.chapterFinished && allEventsComplete,
|
||||
)
|
||||
const unlockedPageIndex = getUnlockedPageIndex(
|
||||
model,
|
||||
completedEventIds,
|
||||
chapterFinished,
|
||||
)
|
||||
const requestedPageId = (
|
||||
cleanString(progress.currentPageId)
|
||||
|| cleanString(progress.savedPageId)
|
||||
|| model.firstPageId
|
||||
|| pages[0].pageId
|
||||
)
|
||||
const requestedIndex = pageIndex(model, requestedPageId)
|
||||
const currentPageIndex = Math.min(
|
||||
unlockedPageIndex,
|
||||
Math.max(0, requestedIndex >= 0 ? requestedIndex : 0),
|
||||
)
|
||||
const currentPage = pages[currentPageIndex]
|
||||
const completed = new Set(completedEventIds)
|
||||
|
||||
let activeInteraction = null
|
||||
if (
|
||||
currentPage.type === 'event'
|
||||
&& !completed.has(currentPage.eventId)
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'event',
|
||||
id: currentPage.eventId,
|
||||
eventId: currentPage.eventId,
|
||||
pageId: currentPage.pageId,
|
||||
actorInstanceId: currentPage.actorInstanceId || '',
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
} else if (
|
||||
currentPage.type === 'emotion'
|
||||
&& allEventsComplete
|
||||
&& !chapterFinished
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'emotion',
|
||||
id: currentPage.emotionMomentId,
|
||||
emotionMomentId: currentPage.emotionMomentId,
|
||||
pageId: currentPage.pageId,
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
}
|
||||
|
||||
const activeHotspots = (
|
||||
activeInteraction
|
||||
&& activeInteraction.actorHotspot
|
||||
)
|
||||
? [{
|
||||
interactionId: activeInteraction.id,
|
||||
pageId: activeInteraction.pageId,
|
||||
actorHotspot: activeInteraction.actorHotspot,
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
currentPage,
|
||||
currentPageId: currentPage.pageId,
|
||||
currentPageIndex,
|
||||
unlockedPageIndex,
|
||||
unlockedPageIds: pages
|
||||
.slice(0, unlockedPageIndex + 1)
|
||||
.map((page) => page.pageId),
|
||||
completedEventIds,
|
||||
allEventsComplete,
|
||||
chapterFinished,
|
||||
canGoPrevious: currentPageIndex > 0,
|
||||
canGoNext: currentPageIndex < unlockedPageIndex,
|
||||
nextPageLocked: (
|
||||
currentPageIndex < pages.length - 1
|
||||
&& currentPageIndex >= unlockedPageIndex
|
||||
),
|
||||
activeInteraction,
|
||||
activeHotspots,
|
||||
pageAccess: pages.map((page, index) => ({
|
||||
pageId: page.pageId,
|
||||
pageIndex: index,
|
||||
type: page.type,
|
||||
unlocked: index <= unlockedPageIndex,
|
||||
current: index === currentPageIndex,
|
||||
interactionEnabled: Boolean(
|
||||
activeInteraction
|
||||
&& activeInteraction.pageId === page.pageId
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function progressFromState(state) {
|
||||
return {
|
||||
currentPageId: state.currentPageId,
|
||||
completedEventIds: [...state.completedEventIds],
|
||||
chapterFinished: state.chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one chapter from the shared v1 storage object.
|
||||
*
|
||||
* `comicReaderByChapter` is the canonical reader cursor. The older
|
||||
* completedHotspots/completedChapters/lastPageId fields remain mirrored for
|
||||
* catalog compatibility and safe migration from already-installed builds.
|
||||
*/
|
||||
function readComicReaderProgress(storageProgress = {}, model = {}, chapterNumber) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
const storedByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? storageProgress.comicReaderByChapter
|
||||
: {}
|
||||
const storedChapter = (
|
||||
chapterId
|
||||
&& storedByChapter[chapterId]
|
||||
&& typeof storedByChapter[chapterId] === 'object'
|
||||
)
|
||||
? storedByChapter[chapterId]
|
||||
: {}
|
||||
const legacyHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
&& Array.isArray(storageProgress.completedHotspots[chapterId])
|
||||
)
|
||||
? storageProgress.completedHotspots[chapterId]
|
||||
: []
|
||||
const legacyFinished = (
|
||||
Array.isArray(storageProgress.completedChapters)
|
||||
&& storageProgress.completedChapters.includes(chapterId)
|
||||
)
|
||||
const chapterFinished = (
|
||||
typeof storedChapter.chapterFinished === 'boolean'
|
||||
? storedChapter.chapterFinished
|
||||
: legacyFinished
|
||||
)
|
||||
const isLastChapter = (
|
||||
Number(storageProgress.lastChapter) === Number(chapterNumber)
|
||||
)
|
||||
|
||||
return {
|
||||
currentPageId: (
|
||||
cleanString(storedChapter.currentPageId)
|
||||
|| (isLastChapter ? cleanString(storageProgress.lastPageId) : '')
|
||||
|| (chapterFinished ? cleanString(model.lastPageId) : '')
|
||||
|| cleanString(model.firstPageId)
|
||||
),
|
||||
completedEventIds: Array.isArray(storedChapter.completedEventIds)
|
||||
? [...storedChapter.completedEventIds]
|
||||
: [...legacyHotspots],
|
||||
chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge only the current chapter back into shared storage.
|
||||
*
|
||||
* Other chapters and unrelated product fields are copied through untouched.
|
||||
* The supplied reader progress is normalized again here so stale `finished`
|
||||
* flags and out-of-order future event ids cannot be persisted.
|
||||
*/
|
||||
function mergeComicReaderProgress(
|
||||
storageProgress = {},
|
||||
model = {},
|
||||
chapterNumber,
|
||||
readerProgress = {},
|
||||
) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
if (!chapterId || !isEightPageComicModel(model)) {
|
||||
return { ...storageProgress }
|
||||
}
|
||||
const state = buildComicReaderState(model, readerProgress)
|
||||
const normalized = progressFromState(state)
|
||||
const completedHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
)
|
||||
? { ...storageProgress.completedHotspots }
|
||||
: {}
|
||||
const comicReaderByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? { ...storageProgress.comicReaderByChapter }
|
||||
: {}
|
||||
const completedChapters = Array.isArray(storageProgress.completedChapters)
|
||||
? storageProgress.completedChapters.filter(
|
||||
(storedChapterId) => storedChapterId !== chapterId,
|
||||
)
|
||||
: []
|
||||
|
||||
completedHotspots[chapterId] = [...normalized.completedEventIds]
|
||||
comicReaderByChapter[chapterId] = normalized
|
||||
if (normalized.chapterFinished) completedChapters.push(chapterId)
|
||||
|
||||
return {
|
||||
...storageProgress,
|
||||
completedHotspots,
|
||||
completedChapters,
|
||||
comicReaderByChapter,
|
||||
lastChapter: Number(chapterNumber) || model.chapterNumber || 1,
|
||||
lastPageId: normalized.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transition helper. Completing an interaction unlocks the following
|
||||
* page but never turns it automatically, so the reader still controls the
|
||||
* lianhuanhua rhythm.
|
||||
*/
|
||||
function applyComicReaderAction(model, progress = {}, action = {}) {
|
||||
const state = buildComicReaderState(model, progress)
|
||||
if (!state.valid) return progressFromState(state)
|
||||
const type = cleanString(action.type)
|
||||
let nextProgress = progressFromState(state)
|
||||
|
||||
if (type === 'previous-page' && state.canGoPrevious) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex - 1
|
||||
].pageId
|
||||
} else if (type === 'next-page' && state.canGoNext) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex + 1
|
||||
].pageId
|
||||
} else if (type === 'open-page') {
|
||||
const targetIndex = pageIndex(model, action.pageId)
|
||||
if (targetIndex >= 0 && targetIndex <= state.unlockedPageIndex) {
|
||||
nextProgress.currentPageId = comicPages(model)[targetIndex].pageId
|
||||
}
|
||||
} else if (
|
||||
type === 'complete-event'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'event'
|
||||
&& state.activeInteraction.eventId === cleanString(action.eventId)
|
||||
) {
|
||||
nextProgress.completedEventIds = [
|
||||
...state.completedEventIds,
|
||||
state.activeInteraction.eventId,
|
||||
]
|
||||
} else if (
|
||||
type === 'complete-emotion'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'emotion'
|
||||
&& state.activeInteraction.emotionMomentId
|
||||
=== cleanString(action.emotionMomentId)
|
||||
) {
|
||||
nextProgress.chapterFinished = true
|
||||
}
|
||||
|
||||
return progressFromState(buildComicReaderState(model, nextProgress))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared illustration path is not proof that a finished page exists.
|
||||
* Callers must explicitly confirm formalAvailable after package/manifest
|
||||
* validation. Otherwise the state is visibly a fallback, never "formal art".
|
||||
*/
|
||||
function buildComicArtAvailability(page = {}, options = {}) {
|
||||
const formalAsset = cleanString(
|
||||
options.formalAsset || page.illustrationAsset,
|
||||
)
|
||||
const fallbackAsset = cleanString(
|
||||
options.fallbackAsset || page.fallbackAsset,
|
||||
)
|
||||
if (options.formalAvailable === true && formalAsset) {
|
||||
return {
|
||||
mode: 'formal-art',
|
||||
src: formalAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: true,
|
||||
isFallback: false,
|
||||
}
|
||||
}
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
mode: 'scene-fallback',
|
||||
src: fallbackAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'text-only',
|
||||
src: '',
|
||||
formalAsset,
|
||||
fallbackAsset: '',
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_PAGE_TYPES,
|
||||
applyComicReaderAction,
|
||||
buildComicArtAvailability,
|
||||
buildComicReaderState,
|
||||
getUnlockedPageIndex,
|
||||
isEightPageComicModel,
|
||||
mergeComicReaderProgress,
|
||||
normalizeCompletedEventIds,
|
||||
pageIndex,
|
||||
readComicReaderProgress,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 发布环境可覆盖这些值。
|
||||
*
|
||||
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
|
||||
* 使用包内种子图或返回文字回退,不会发起网络请求。
|
||||
*/
|
||||
module.exports = {
|
||||
cdnBaseUrl: '',
|
||||
imageCacheBudgetBytes: 28 * 1024 * 1024,
|
||||
audioCacheBudgetBytes: 32 * 1024 * 1024,
|
||||
audioCacheMaxEntries: 32,
|
||||
prefetchAhead: 2,
|
||||
downloadConcurrency: 2,
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
const { releaseAssets } = require('./releaseAssetManifest')
|
||||
|
||||
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
|
||||
const RUNTIME_VISUAL_TIERS = Object.freeze({
|
||||
FORMAL: 'formal',
|
||||
PROVISIONAL: 'experience-provisional',
|
||||
FALLBACK: 'fallback',
|
||||
})
|
||||
|
||||
// This set deliberately mirrors `usable` in
|
||||
// docs/production/art-release-gate-s01.json. Registered page art outside this
|
||||
// set may be shown only as experience-provisional and never counts as formal.
|
||||
const FORMAL_PAGE_ART_IDS = new Set([
|
||||
'S01-C01-P01',
|
||||
'S01-C01-P02',
|
||||
'S01-C01-P03',
|
||||
'S01-C01-P04',
|
||||
'S01-C01-P05',
|
||||
'S01-C01-P07',
|
||||
'S01-C01-P08',
|
||||
'S01-C02-P01',
|
||||
'S01-C02-P02',
|
||||
'S01-C02-P03',
|
||||
'S01-C02-P04',
|
||||
'S01-C02-P05',
|
||||
'S01-C02-P06',
|
||||
'S01-C02-P07',
|
||||
'S01-C02-P08',
|
||||
'S01-C03-P01',
|
||||
'S01-C03-P02',
|
||||
'S01-C03-P03',
|
||||
'S01-C03-P04',
|
||||
'S01-C03-P05',
|
||||
'S01-C03-P07',
|
||||
'S01-C03-P08',
|
||||
'S01-C04-P01',
|
||||
'S01-C04-P02',
|
||||
'S01-C04-P03',
|
||||
'S01-C04-P05',
|
||||
'S01-C04-P06',
|
||||
'S01-C04-P07',
|
||||
'S01-C04-P08',
|
||||
'S01-C05-P01',
|
||||
'S01-C05-P02',
|
||||
'S01-C05-P03',
|
||||
'S01-C05-P04',
|
||||
'S01-C05-P05',
|
||||
'S01-C05-P06',
|
||||
'S01-C05-P07',
|
||||
'S01-C05-P08',
|
||||
'S01-C06-P01',
|
||||
'S01-C06-P02',
|
||||
'S01-C06-P03',
|
||||
'S01-C06-P04',
|
||||
'S01-C06-P05',
|
||||
'S01-C06-P06',
|
||||
'S01-C06-P07',
|
||||
'S01-C06-P08',
|
||||
'S01-C07-P01',
|
||||
'S01-C07-P02',
|
||||
'S01-C07-P03',
|
||||
'S01-C07-P04',
|
||||
'S01-C07-P05',
|
||||
'S01-C07-P06',
|
||||
'S01-C07-P07',
|
||||
'S01-C07-P08',
|
||||
'S01-C08-P02',
|
||||
'S01-C08-P03',
|
||||
'S01-C08-P04',
|
||||
'S01-C08-P05',
|
||||
'S01-C08-P06',
|
||||
'S01-C08-P07',
|
||||
'S01-C08-P08',
|
||||
'S01-C09-P02',
|
||||
'S01-C09-P03',
|
||||
'S01-C09-P04',
|
||||
'S01-C09-P07',
|
||||
'S01-C09-P08',
|
||||
'S01-C10-P01',
|
||||
'S01-C10-P02',
|
||||
'S01-C10-P03',
|
||||
'S01-C10-P04',
|
||||
'S01-C10-P05',
|
||||
'S01-C10-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C10-P08',
|
||||
'S01-C11-P01',
|
||||
'S01-C11-P02',
|
||||
'S01-C11-P03',
|
||||
'S01-C11-P04',
|
||||
'S01-C11-P05',
|
||||
'S01-C11-P06',
|
||||
'S01-C11-P07',
|
||||
'S01-C11-P08',
|
||||
'S01-C12-P01',
|
||||
'S01-C12-P02',
|
||||
'S01-C12-P03',
|
||||
'S01-C12-P04',
|
||||
'S01-C12-P05',
|
||||
'S01-C12-P06',
|
||||
'S01-C12-P07',
|
||||
'S01-C12-P08',
|
||||
'S01-C13-P01',
|
||||
'S01-C13-P02',
|
||||
'S01-C13-P03',
|
||||
'S01-C13-P05',
|
||||
'S01-C13-P06',
|
||||
'S01-C13-P07',
|
||||
'S01-C13-P08',
|
||||
'S01-C14-P01',
|
||||
'S01-C14-P02',
|
||||
'S01-C14-P03',
|
||||
'S01-C14-P04',
|
||||
'S01-C14-P05',
|
||||
'S01-C14-P06',
|
||||
'S01-C14-P07',
|
||||
'S01-C14-P08',
|
||||
'S01-C15-P01',
|
||||
'S01-C15-P02',
|
||||
'S01-C15-P03',
|
||||
'S01-C15-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P06',
|
||||
'S01-C15-P07',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicAssetIdForPageId(pageId) {
|
||||
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
|
||||
if (!match) return ''
|
||||
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
|
||||
}
|
||||
|
||||
function registeredPageArtFor(page) {
|
||||
const assetId = comicAssetIdForPageId(page && page.pageId)
|
||||
const releaseAsset = assetId ? releaseAssets[assetId] : null
|
||||
return Boolean(
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
&& clean(releaseAsset.localSeed)
|
||||
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
|
||||
)
|
||||
}
|
||||
|
||||
function createPageArtVisual(page, chapter, runtimeTier) {
|
||||
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
|
||||
return {
|
||||
kind: 'page-art',
|
||||
asset: clean(page.illustrationAsset),
|
||||
subject: clean(page.actorName || page.headline || chapter.title),
|
||||
runtimeTier,
|
||||
formalReleaseEligible: formal,
|
||||
reviewStatus: formal
|
||||
? PLAYABLE_VISUAL_REVIEW_STATUS
|
||||
: PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: formal
|
||||
? 'art-release-gate-s01-usable'
|
||||
: 'registered-immutable-asset-experience-only',
|
||||
}
|
||||
}
|
||||
|
||||
function personByInstanceId(chapter, instanceId) {
|
||||
return (chapter.people || []).find(
|
||||
(person) => (
|
||||
person.instanceId === instanceId
|
||||
&& clean(person.portrait)
|
||||
),
|
||||
) || null
|
||||
}
|
||||
|
||||
function personByName(chapter, name) {
|
||||
const cleanName = clean(name)
|
||||
if (!cleanName) return null
|
||||
return (chapter.people || []).find(
|
||||
(person) => person.name === cleanName && clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function featuredPersonForPage(page, chapter) {
|
||||
const direct = personByInstanceId(chapter, page.actorInstanceId)
|
||||
if (direct) return direct
|
||||
|
||||
if (page.type === 'memory') {
|
||||
const memoryPerson = personByName(
|
||||
chapter,
|
||||
page.memoryCard && page.memoryCard.characterName,
|
||||
)
|
||||
if (memoryPerson) return memoryPerson
|
||||
}
|
||||
|
||||
if (page.type === 'emotion') {
|
||||
const emotionPerson = personByName(
|
||||
chapter,
|
||||
chapter.emotionMoment
|
||||
&& chapter.emotionMoment.character
|
||||
&& chapter.emotionMoment.character.name,
|
||||
)
|
||||
if (emotionPerson) return emotionPerson
|
||||
}
|
||||
|
||||
return (chapter.people || []).find(
|
||||
(person) => clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function createPlayableVisual(page, chapter) {
|
||||
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
|
||||
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
|
||||
}
|
||||
|
||||
if (registeredPageArtFor(page)) {
|
||||
return createPageArtVisual(
|
||||
page,
|
||||
chapter,
|
||||
RUNTIME_VISUAL_TIERS.PROVISIONAL,
|
||||
)
|
||||
}
|
||||
|
||||
if (page.type === 'event') {
|
||||
const portrait = clean(page.actorPortrait)
|
||||
if (portrait) {
|
||||
return {
|
||||
kind: 'actor-portrait',
|
||||
asset: portrait,
|
||||
subject: clean(page.actorName),
|
||||
actorInstanceId: clean(page.actorInstanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.actorName),
|
||||
evidenceLabel: clean(page.headline || '看看手边证据'),
|
||||
evidenceDetail: clean(
|
||||
page.secondaryCaption || page.caption || page.question,
|
||||
),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPerson = featuredPersonForPage(page, chapter)
|
||||
if (featuredPerson) {
|
||||
return {
|
||||
kind: 'chapter-character',
|
||||
asset: clean(featuredPerson.portrait),
|
||||
subject: clean(featuredPerson.name),
|
||||
actorInstanceId: clean(featuredPerson.instanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive last resort. The season currently never reaches this branch,
|
||||
// but if a future chapter lacks a portrait it still gets explicit evidence
|
||||
// instead of an unexplained empty room.
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.headline || chapter.title),
|
||||
evidenceLabel: clean(page.headline || chapter.title),
|
||||
evidenceDetail: clean(page.caption || chapter.narration),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
function attachPlayableVisuals(pageSequence, chapter) {
|
||||
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
|
||||
...page,
|
||||
playableVisual: createPlayableVisual(page, chapter),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORMAL_PAGE_ART_IDS,
|
||||
PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
RUNTIME_VISUAL_TIERS,
|
||||
attachPlayableVisuals,
|
||||
createPlayableVisual,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Generated package-local production comic data. Do not hand-edit.
|
||||
module.exports = {}
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* 发布固定资产清单 v1
|
||||
*
|
||||
* 约束:
|
||||
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
|
||||
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
|
||||
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
|
||||
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
|
||||
*/
|
||||
|
||||
const RELEASE_ASSET_MANIFEST_VERSION = 1
|
||||
|
||||
const releaseAssets = {
|
||||
'comic.s01.c01.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
|
||||
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
|
||||
},
|
||||
'comic.s01.c01.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
|
||||
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
|
||||
},
|
||||
'comic.s01.c01.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
|
||||
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
|
||||
},
|
||||
'comic.s01.c01.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
|
||||
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
|
||||
},
|
||||
'comic.s01.c01.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
|
||||
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
|
||||
},
|
||||
'comic.s01.c01.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
|
||||
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
|
||||
},
|
||||
'comic.s01.c01.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
|
||||
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
|
||||
},
|
||||
'comic.s01.c01.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
|
||||
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
|
||||
},
|
||||
'comic.s01.c02.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
|
||||
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
|
||||
},
|
||||
'comic.s01.c02.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
|
||||
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
|
||||
},
|
||||
'comic.s01.c02.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
|
||||
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
|
||||
},
|
||||
'comic.s01.c02.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
|
||||
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
|
||||
},
|
||||
'comic.s01.c02.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
|
||||
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
|
||||
},
|
||||
'comic.s01.c02.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
|
||||
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
|
||||
},
|
||||
'comic.s01.c02.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
|
||||
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
|
||||
},
|
||||
'comic.s01.c02.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
|
||||
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
|
||||
},
|
||||
'comic.s01.c03.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
|
||||
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
|
||||
},
|
||||
'comic.s01.c03.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
|
||||
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
|
||||
},
|
||||
'comic.s01.c03.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
|
||||
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
|
||||
},
|
||||
'comic.s01.c03.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
|
||||
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
|
||||
},
|
||||
'comic.s01.c03.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
|
||||
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
|
||||
},
|
||||
'comic.s01.c03.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
|
||||
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
|
||||
},
|
||||
'comic.s01.c03.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
|
||||
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
|
||||
},
|
||||
'comic.s01.c03.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
|
||||
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
|
||||
},
|
||||
'comic.s01.c04.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
|
||||
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
|
||||
},
|
||||
'comic.s01.c04.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
|
||||
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
|
||||
},
|
||||
'comic.s01.c04.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
|
||||
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
|
||||
},
|
||||
'comic.s01.c04.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
|
||||
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
|
||||
},
|
||||
'comic.s01.c04.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
|
||||
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
|
||||
},
|
||||
'comic.s01.c04.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
|
||||
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
|
||||
},
|
||||
'comic.s01.c04.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
|
||||
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
|
||||
},
|
||||
'comic.s01.c04.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
|
||||
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
|
||||
},
|
||||
'comic.s01.c05.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
|
||||
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
|
||||
},
|
||||
'comic.s01.c05.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
|
||||
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
|
||||
},
|
||||
'comic.s01.c05.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
|
||||
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
|
||||
},
|
||||
'comic.s01.c05.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
|
||||
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
|
||||
},
|
||||
'comic.s01.c05.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
|
||||
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
|
||||
},
|
||||
'comic.s01.c05.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
|
||||
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
|
||||
},
|
||||
'comic.s01.c05.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
|
||||
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
|
||||
},
|
||||
'comic.s01.c05.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
|
||||
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
|
||||
},
|
||||
'comic.s01.c06.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
|
||||
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
|
||||
},
|
||||
'comic.s01.c06.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
|
||||
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
|
||||
},
|
||||
'comic.s01.c06.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
|
||||
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
|
||||
},
|
||||
'comic.s01.c06.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
|
||||
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
|
||||
},
|
||||
'comic.s01.c06.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
|
||||
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
|
||||
},
|
||||
'comic.s01.c06.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
|
||||
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
|
||||
},
|
||||
'comic.s01.c06.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
|
||||
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
|
||||
},
|
||||
'comic.s01.c06.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
|
||||
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
|
||||
},
|
||||
'comic.s01.c07.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
|
||||
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
|
||||
},
|
||||
'comic.s01.c07.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
|
||||
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
|
||||
},
|
||||
'comic.s01.c07.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
|
||||
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
|
||||
},
|
||||
'comic.s01.c07.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
|
||||
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
|
||||
},
|
||||
'comic.s01.c07.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
|
||||
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
|
||||
},
|
||||
'comic.s01.c07.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
|
||||
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
|
||||
},
|
||||
'comic.s01.c07.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
|
||||
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
|
||||
},
|
||||
'comic.s01.c07.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
|
||||
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
|
||||
},
|
||||
'comic.s01.c08.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
|
||||
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
|
||||
},
|
||||
'comic.s01.c08.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
|
||||
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
|
||||
},
|
||||
'comic.s01.c08.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
|
||||
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
|
||||
},
|
||||
'comic.s01.c08.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
|
||||
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
|
||||
},
|
||||
'comic.s01.c08.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
|
||||
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
|
||||
},
|
||||
'comic.s01.c08.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
|
||||
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
|
||||
},
|
||||
'comic.s01.c08.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
|
||||
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
|
||||
},
|
||||
'comic.s01.c08.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
|
||||
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
|
||||
},
|
||||
'comic.s01.c09.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
|
||||
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
|
||||
},
|
||||
'comic.s01.c09.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
|
||||
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
|
||||
},
|
||||
'comic.s01.c09.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
|
||||
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
|
||||
},
|
||||
'comic.s01.c09.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
|
||||
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
|
||||
},
|
||||
'comic.s01.c09.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
|
||||
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
|
||||
},
|
||||
'comic.s01.c09.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
|
||||
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
|
||||
},
|
||||
'comic.s01.c09.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
|
||||
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
|
||||
},
|
||||
'comic.s01.c09.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
|
||||
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
|
||||
},
|
||||
'comic.s01.c10.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
|
||||
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
|
||||
},
|
||||
'comic.s01.c10.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
|
||||
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
|
||||
},
|
||||
'comic.s01.c10.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
|
||||
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
|
||||
},
|
||||
'comic.s01.c10.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
|
||||
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
|
||||
},
|
||||
'comic.s01.c10.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
|
||||
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
|
||||
},
|
||||
'comic.s01.c10.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
|
||||
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
|
||||
},
|
||||
'comic.s01.c10.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
|
||||
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
|
||||
},
|
||||
'comic.s01.c10.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
|
||||
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
|
||||
},
|
||||
'comic.s01.c11.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
|
||||
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
|
||||
},
|
||||
'comic.s01.c11.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
|
||||
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
|
||||
},
|
||||
'comic.s01.c11.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
|
||||
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
|
||||
},
|
||||
'comic.s01.c11.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
|
||||
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
|
||||
},
|
||||
'comic.s01.c11.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
|
||||
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
|
||||
},
|
||||
'comic.s01.c11.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
|
||||
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
|
||||
},
|
||||
'comic.s01.c11.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
|
||||
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
|
||||
},
|
||||
'comic.s01.c11.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
|
||||
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
|
||||
},
|
||||
'comic.s01.c12.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
|
||||
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
|
||||
},
|
||||
'comic.s01.c12.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
|
||||
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
|
||||
},
|
||||
'comic.s01.c12.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
|
||||
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
|
||||
},
|
||||
'comic.s01.c12.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
|
||||
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
|
||||
},
|
||||
'comic.s01.c12.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
|
||||
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
|
||||
},
|
||||
'comic.s01.c12.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
|
||||
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
|
||||
},
|
||||
'comic.s01.c12.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
|
||||
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
|
||||
},
|
||||
'comic.s01.c12.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
|
||||
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
|
||||
},
|
||||
'comic.s01.c13.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
|
||||
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
|
||||
},
|
||||
'comic.s01.c13.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
|
||||
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
|
||||
},
|
||||
'comic.s01.c13.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
|
||||
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
|
||||
},
|
||||
'comic.s01.c13.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
|
||||
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
|
||||
},
|
||||
'comic.s01.c13.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
|
||||
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
|
||||
},
|
||||
'comic.s01.c13.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
|
||||
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
|
||||
},
|
||||
'comic.s01.c13.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
|
||||
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
|
||||
},
|
||||
'comic.s01.c13.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
|
||||
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
|
||||
},
|
||||
'comic.s01.c14.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
|
||||
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
|
||||
},
|
||||
'comic.s01.c14.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
|
||||
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
|
||||
},
|
||||
'comic.s01.c14.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
|
||||
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
|
||||
},
|
||||
'comic.s01.c14.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
|
||||
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
|
||||
},
|
||||
'comic.s01.c14.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
|
||||
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
|
||||
},
|
||||
'comic.s01.c14.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
|
||||
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
|
||||
},
|
||||
'comic.s01.c14.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
|
||||
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
|
||||
},
|
||||
'comic.s01.c14.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
|
||||
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
|
||||
},
|
||||
'comic.s01.c15.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
|
||||
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
|
||||
},
|
||||
'comic.s01.c15.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
|
||||
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
|
||||
},
|
||||
'comic.s01.c15.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
|
||||
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
|
||||
},
|
||||
'comic.s01.c15.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
|
||||
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
|
||||
},
|
||||
'comic.s01.c15.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
|
||||
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
|
||||
},
|
||||
'comic.s01.c15.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
|
||||
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
|
||||
},
|
||||
'comic.s01.c15.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
|
||||
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
|
||||
},
|
||||
'comic.s01.c15.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
|
||||
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
|
||||
},
|
||||
'character.female-cook': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/female-cook.jpg',
|
||||
remotePath: 'characters/female-cook.c56420b2c632.jpg',
|
||||
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
|
||||
},
|
||||
'character.lele': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lele.jpg',
|
||||
remotePath: 'characters/lele.807f5232b1e8.jpg',
|
||||
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
|
||||
},
|
||||
'character.lin-xiulan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lin-xiulan.jpg',
|
||||
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
|
||||
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
|
||||
},
|
||||
'character.qin-xiaoman': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-xiaoman.jpg',
|
||||
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
|
||||
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
|
||||
},
|
||||
'character.qin-zhicheng': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-zhicheng.jpg',
|
||||
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
|
||||
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
|
||||
},
|
||||
'character.tang-mingyuan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-mingyuan.jpg',
|
||||
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
|
||||
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
|
||||
},
|
||||
'character.tang-shouan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-shouan.jpg',
|
||||
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
|
||||
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
|
||||
},
|
||||
'character.xiaozhen': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/xiaozhen.jpg',
|
||||
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
|
||||
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
|
||||
},
|
||||
'character.zhao-jianguo': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/zhao-jianguo.jpg',
|
||||
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
|
||||
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
|
||||
},
|
||||
'scene.gui-xiang.1978': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
|
||||
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
|
||||
},
|
||||
'scene.gui-xiang.1995': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
|
||||
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
|
||||
},
|
||||
'scene.gui-xiang.1998': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
|
||||
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
|
||||
},
|
||||
'scene.gui-xiang.2001': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
|
||||
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
|
||||
},
|
||||
'scene.gui-xiang.2003': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
|
||||
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
|
||||
},
|
||||
'scene.gui-xiang.2008': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
|
||||
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
|
||||
},
|
||||
'scene.gui-xiang.2026': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
|
||||
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
|
||||
},
|
||||
'audio.cast-audition.v1': {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'audition',
|
||||
localSeed: '',
|
||||
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
|
||||
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
|
||||
// The reader no longer maps them as page narration; C01 uses full-page tracks
|
||||
// through the two package-local audio player pages instead.
|
||||
for (const cueId of [
|
||||
'S01-C01-MS007','S01-C01-MS010',
|
||||
]) {
|
||||
const file = `${cueId}.mp3`
|
||||
const sha256 = {
|
||||
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
|
||||
}[cueId]
|
||||
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
|
||||
}
|
||||
|
||||
const audioC02Sha256 = {
|
||||
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
|
||||
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
|
||||
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
|
||||
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
|
||||
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
|
||||
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
|
||||
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
|
||||
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
|
||||
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
|
||||
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
|
||||
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
|
||||
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
|
||||
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
|
||||
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
|
||||
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
|
||||
}
|
||||
|
||||
const audioC03Sha256 = {
|
||||
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
|
||||
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
|
||||
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
|
||||
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
|
||||
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
|
||||
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
|
||||
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
|
||||
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
|
||||
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
|
||||
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
|
||||
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
|
||||
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
|
||||
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
|
||||
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
|
||||
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
|
||||
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
|
||||
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
|
||||
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
|
||||
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
|
||||
}
|
||||
|
||||
// C02/C03 cue files are retained only as rollback/source material. A single
|
||||
// cue is not a complete page track, so these assets must not pass the runtime
|
||||
// audio gate or appear as “试听本页”.
|
||||
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
|
||||
for (const [cueId, sha256] of Object.entries(audioHashes)) {
|
||||
const file = `${cueId}.mp3`
|
||||
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'reserved',
|
||||
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
|
||||
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RELEASE_ASSET_MANIFEST_VERSION,
|
||||
releaseAssets,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* C02-C15 remote full-page audio manifest v1.
|
||||
*
|
||||
* Import contract for a future reviewed batch:
|
||||
*
|
||||
* 'S01-C09-P05': Object.freeze({
|
||||
* pageId: 'S01-C09-P05',
|
||||
* chapterNumber: 9,
|
||||
* pageNumber: 5,
|
||||
* assetId: 'audio.page.s01.c09.p05',
|
||||
* durationSeconds: 42.1,
|
||||
* reviewStatus: 'approved',
|
||||
* })
|
||||
*
|
||||
* The matching releaseAssetManifest entry must independently be an approved,
|
||||
* remote-only, immutable hash-named audio asset. Automated QA, pending human
|
||||
* listening, and missing CDN records must never be relabelled as approved.
|
||||
*/
|
||||
|
||||
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
|
||||
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
// Deliberately empty until full-page tracks pass the required human reviews
|
||||
// and their immutable CDN objects are available. This is a release gate, not
|
||||
// a placeholder that the UI may treat as playable.
|
||||
const remotePageAudioPages = Object.freeze({})
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function remotePageAudioAssetId(pageId) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return ''
|
||||
return `audio.page.s01.c${match[1]}.p${match[2]}`
|
||||
}
|
||||
|
||||
function getApprovedRemotePageAudio(
|
||||
pageId,
|
||||
releaseAssets = {},
|
||||
pageManifest = remotePageAudioPages,
|
||||
) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return null
|
||||
|
||||
const entry = pageManifest && pageManifest[stablePageId]
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const chapterNumber = Number(match[1])
|
||||
const pageNumber = Number(match[2])
|
||||
const assetId = remotePageAudioAssetId(stablePageId)
|
||||
const durationSeconds = Number(entry.durationSeconds)
|
||||
if (
|
||||
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| entry.pageId !== stablePageId
|
||||
|| Number(entry.chapterNumber) !== chapterNumber
|
||||
|| Number(entry.pageNumber) !== pageNumber
|
||||
|| entry.assetId !== assetId
|
||||
|| !Number.isFinite(durationSeconds)
|
||||
|| durationSeconds <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const asset = releaseAssets && releaseAssets[assetId]
|
||||
const sha256 = asset && typeof asset.sha256 === 'string'
|
||||
? asset.sha256
|
||||
: ''
|
||||
const remotePath = asset && asset.remotePath
|
||||
const expectedRemotePath = (
|
||||
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
|
||||
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
|
||||
)
|
||||
if (
|
||||
!asset
|
||||
|| asset.kind !== 'audio'
|
||||
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| asset.localSeed !== ''
|
||||
|| !SHA256_PATTERN.test(sha256)
|
||||
|| typeof remotePath !== 'string'
|
||||
|| remotePath !== expectedRemotePath
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...entry, assetId }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REMOTE_PAGE_AUDIO_ID_PATTERN,
|
||||
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
|
||||
getApprovedRemotePageAudio,
|
||||
remotePageAudioAssetId,
|
||||
remotePageAudioPages,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "唐侦探",
|
||||
"pageOrientation": "landscape",
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
<view
|
||||
wx:if="{{chapter}}"
|
||||
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
|
||||
style="{{pageStyle}}"
|
||||
>
|
||||
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
|
||||
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
|
||||
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录">‹ 目录</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
|
||||
class="comic-top-previous-button"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一张画"
|
||||
>‹</button>
|
||||
<view class="comic-bookmark">
|
||||
<text class="comic-bookmark-label">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
|
||||
</text>
|
||||
<text class="comic-page-count">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
</view>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
|
||||
bindtap="openTextReading"
|
||||
aria-label="本页可以直接看文字"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text class="comic-page-audio-label">直接看文字</text>
|
||||
<text class="comic-page-audio-meta">按页慢慢看</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
|
||||
disabled="{{audioLoading}}"
|
||||
bindtap="toggleComicPageAudio"
|
||||
data-audio-cue-count="{{currentPage.audioCueCount}}"
|
||||
aria-label="本页语音播放控制"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">
|
||||
{{audioPlaying ? 'Ⅱ' : '▶'}}
|
||||
</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
|
||||
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
|
||||
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
|
||||
<text wx:else class="comic-page-audio-label">听一听</text>
|
||||
<text class="comic-page-audio-meta">
|
||||
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
|
||||
</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
|
||||
class="comic-font-button"
|
||||
bindtap="toggleFont"
|
||||
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
|
||||
>
|
||||
{{fontScale === 'large' ? '大字' : '标准字'}}
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
|
||||
class="c1-front-action-button"
|
||||
bindtap="openCurrentFrontAction"
|
||||
aria-label="{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
|
||||
: '点一下,翻到背面'}}"
|
||||
>
|
||||
{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '再看背面' : '点点人物')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '看看章末' : '听听心里话')
|
||||
: '翻到背面'}}
|
||||
</button>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view class="comic-reader">
|
||||
<block wx:if="{{storyFirstComicMode}}">
|
||||
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
|
||||
<block wx:if="{{chapterOneBackOpen}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">
|
||||
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边回声</text>
|
||||
<text>{{currentPage.memoryCard.tableEcho}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">带回生活里</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
<view class="c1-memory-secondary-actions">
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>看看生活观察</button>
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>发给家人聊聊</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{chapterOneReviewEvent.evidence}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边一句话</text>
|
||||
<text>“{{currentPage.shortDialogue}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text
|
||||
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
|
||||
class="c1-scroll-hint"
|
||||
>上划接着看 ↑</text>
|
||||
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
|
||||
</view>
|
||||
<view wx:else class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button
|
||||
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
|
||||
class="c1-back-button primary"
|
||||
bindtap="startChapterOneDecisionFromBack"
|
||||
>聊聊这事</button>
|
||||
<button
|
||||
wx:else
|
||||
class="c1-back-button primary"
|
||||
bindtap="nextChapterOnePageFromBack"
|
||||
>翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边回声</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">没有标准答案</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{emotionResult.feedback}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">这一桌记住了</text>
|
||||
<text>{{emotionResult.echo}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
|
||||
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
|
||||
<view class="c1-interaction-paper c1-emotion-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边一句话</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
|
||||
<text class="c1-tray-question">{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="c1-natural-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>{{item.text}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view class="c1-front-stage">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-front-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="c1-front-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="c1-front-grain"></view>
|
||||
|
||||
<button
|
||||
wx:if="{{!frontControlsRevealed}}"
|
||||
class="c1-front-reveal-gate"
|
||||
bindtap="revealCurrentFrontControls"
|
||||
aria-label="轻点画面,显示阅读按钮"
|
||||
></button>
|
||||
|
||||
<block wx:else>
|
||||
<button
|
||||
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
|
||||
class="c1-person-hotspot"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
|
||||
><view class="c1-hotspot-ring"></view></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="翻到这一幕的背面"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneEmotion"
|
||||
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:else
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="轻触画面,翻到这一页背面"
|
||||
></button>
|
||||
</block>
|
||||
|
||||
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
|
||||
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion'}}">
|
||||
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="special-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="special-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="emotion-panel">
|
||||
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="emotion-context">
|
||||
<text class="emotion-context-kicker">听听这一桌心里话</text>
|
||||
<text class="emotion-context-title">{{currentPage.headline}}</text>
|
||||
<text class="emotion-context-caption">{{currentPage.caption}}</text>
|
||||
</view>
|
||||
<text class="emotion-prompt">{{currentPage.prompt}}</text>
|
||||
<text
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-note"
|
||||
>没有标准答案,选您更愿意说的话。</text>
|
||||
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
|
||||
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
|
||||
<view class="table-echo">
|
||||
<text class="echo-label">桌边回声</text>
|
||||
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-choice-dock"
|
||||
>
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="emotion-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>
|
||||
<text class="choice-number">{{index + 1}}</text>
|
||||
<text>{{item.text}}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
|
||||
<button
|
||||
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{emotionResult || chapterFinished}}"
|
||||
class="special-page-button primary"
|
||||
bindtap="finishComicEmotion"
|
||||
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
|
||||
>
|
||||
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.isSeasonClose}}">
|
||||
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="season-close-body">
|
||||
<view class="season-close-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="season-close-main-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="season-close-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view
|
||||
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
|
||||
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
|
||||
aria-label="{{currentPage.detailInset.label}}近景"
|
||||
>
|
||||
<image
|
||||
class="season-close-detail-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{currentPage.detailInset.imageMode}}"
|
||||
style="{{currentPage.detailInset.imageStyle}}"
|
||||
></image>
|
||||
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="season-close-copy"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="season-close-copy-sheet">
|
||||
<text class="season-close-kicker">第一季 · 季终</text>
|
||||
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
|
||||
<view class="season-close-memory-card">
|
||||
<text class="season-close-card-label">桂香记忆卡</text>
|
||||
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
|
||||
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="season-close-life-line">
|
||||
<text class="season-close-line-label">今天可以这样做</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<view class="season-close-family-line">
|
||||
<text class="season-close-line-label">带回家聊一聊</text>
|
||||
<text>“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="memory-actions season-close-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="第一季读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'memory'}}">
|
||||
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
|
||||
<view class="memory-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="memory-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="memory-panel">
|
||||
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
|
||||
<view class="memory-paper-content">
|
||||
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
|
||||
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
|
||||
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
|
||||
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
|
||||
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
|
||||
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
|
||||
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
|
||||
</button>
|
||||
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="memory-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{chapterNumber >= chapterCount}}"
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="全书读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
<button
|
||||
wx:else
|
||||
class="memory-action-cell primary"
|
||||
bindtap="nextChapter"
|
||||
aria-label="翻到下一回"
|
||||
>
|
||||
<text class="memory-action-visible">翻到下一回</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view
|
||||
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
|
||||
bindtouchstart="onComicTouchStart"
|
||||
bindtouchend="onComicTouchEnd"
|
||||
>
|
||||
<view class="comic-art-column">
|
||||
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{comicPageImageMode}}"
|
||||
style="{{comicPageImageStyle}}"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="comic-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
<view
|
||||
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
|
||||
class="comic-evidence-composite"
|
||||
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
|
||||
>
|
||||
<text class="comic-evidence-kicker">手边证据</text>
|
||||
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
|
||||
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
|
||||
</view>
|
||||
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
|
||||
class="comic-actor-hotspot is-quiet"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
|
||||
></button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
|
||||
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
|
||||
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
|
||||
<view class="comic-cover-caption-heading">
|
||||
<text class="comic-caption-kicker">{{chapter.year}}</text>
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
</view>
|
||||
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
<view class="comic-ensemble-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
|
||||
<view class="comic-event-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
<view class="comic-event-cue">
|
||||
<text
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight}}"
|
||||
class="comic-outside-actor-button"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
|
||||
<text class="seen-chip">看见了</text>
|
||||
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
wx:if="{{currentInteractionEnabled && compactHeight}}"
|
||||
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
|
||||
>
|
||||
<text
|
||||
wx:if="{{currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
class="comic-outside-actor-button comic-outside-actor-dock"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="comic-page-nav">
|
||||
<button
|
||||
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
<button
|
||||
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="nextComicPage"
|
||||
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
|
||||
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">
|
||||
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
|
||||
</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
|
||||
<view
|
||||
class="text-reading-dialog paper-panel"
|
||||
aria-role="dialog"
|
||||
aria-label="本页大字文字"
|
||||
>
|
||||
<button
|
||||
class="dialog-close"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="关闭文字阅读"
|
||||
>×</button>
|
||||
<text class="text-reading-kicker">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
|
||||
</text>
|
||||
<scroll-view
|
||||
class="text-reading-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="text-reading-sheet">
|
||||
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
|
||||
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">这一页想和您聊</text>
|
||||
<text>{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">看看画里发生了什么</text>
|
||||
<text>{{currentPage.question}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<button
|
||||
class="text-reading-return"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="回到连环画"
|
||||
>回到画里</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
|
||||
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">画里的线索</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
|
||||
<text class="c1-tray-question">{{activeEvent.question}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{activeEvent.choiceOptions}}"
|
||||
wx:key="value"
|
||||
class="c1-natural-choice"
|
||||
data-value="{{item.value}}"
|
||||
bindtap="answer"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">再看看画里</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel c1-retry-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
|
||||
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
|
||||
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
|
||||
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-paper c1-event-result-back">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">这下有眉目了</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
|
||||
<text class="c1-back-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<block wx:else>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
<text class="c1-back-reason">{{activeEvent.reason}}</text>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text class="c1-scroll-hint">上划接着看 ↑</text>
|
||||
<view class="c1-back-footer">
|
||||
<button
|
||||
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="c1-back-button secondary"
|
||||
aria-label="回头再看一遍画面"
|
||||
bindtap="returnFromChapterOneDecision"
|
||||
>回去看画</button>
|
||||
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
|
||||
<view
|
||||
class="decision-dialog paper-panel comic-decision"
|
||||
aria-role="dialog"
|
||||
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
|
||||
>
|
||||
<button
|
||||
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="dialog-close"
|
||||
bindtap="closeDecision"
|
||||
aria-label="回到画里"
|
||||
>×</button>
|
||||
|
||||
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="character-story-inner">
|
||||
<view class="comic-focus-visual">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-focus-scene"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
style="{{comicFocusImageStyle}}"
|
||||
></image>
|
||||
<view class="comic-focus-shade"></view>
|
||||
<view class="comic-focus-name">
|
||||
<text>{{activePerson.eraLabel}} · 画中人物</text>
|
||||
<text class="focus-name">{{activeEvent.actorName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="story-label">这一幕发生了什么</text>
|
||||
<text class="action-description">{{activeEvent.actionDescription}}</text>
|
||||
|
||||
<view class="evidence-box">
|
||||
<text class="story-label">手边证据</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
|
||||
<view class="speech-box">
|
||||
<text class="story-label">桌边说话</text>
|
||||
<text>{{activeEvent.speech}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
|
||||
<scroll-view
|
||||
class="judgement-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{decisionResultNeedsScroll}}"
|
||||
>
|
||||
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
|
||||
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
|
||||
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
|
||||
|
||||
<view wx:if="{{resultMode === ''}}" class="judgement-options">
|
||||
<button class="judgement-button observe" data-value="caution" bindtap="answer">
|
||||
<text class="judgement-icon">A</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">先看看现场细节</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="judgement-button continue" data-value="safer" bindtap="answer">
|
||||
<text class="judgement-icon">B</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">继续按画里的做法</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
|
||||
<text class="result-title">没关系,再看看画里的细节。</text>
|
||||
<text>{{activeEvent.retryHint}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
|
||||
<text class="result-title">这处线索对上了。</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
|
||||
<text class="advice-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<view wx:else class="action-advice">
|
||||
<text class="advice-label">现在可以怎么做</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
|
||||
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
|
||||
</view>
|
||||
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="openResultDetail">
|
||||
再看不同情况怎么办
|
||||
</button>
|
||||
</view>
|
||||
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
|
||||
回到画里
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
|
||||
contractId: 'story-first-three-frame-v1',
|
||||
columns: Object.freeze({
|
||||
art: 3,
|
||||
content: 2,
|
||||
}),
|
||||
frames: Object.freeze({
|
||||
front: Object.freeze({
|
||||
imageMode: 'aspectFit',
|
||||
initialControls: 'none',
|
||||
revealGesture: 'single-tap',
|
||||
revealedControls: 'topbar-only',
|
||||
}),
|
||||
back: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
}),
|
||||
interaction: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
choiceCount: 2,
|
||||
choiceDirection: 'vertical',
|
||||
}),
|
||||
}),
|
||||
minimumControlPx: 48,
|
||||
compactHeightMaxPx: 620,
|
||||
fontModes: Object.freeze({
|
||||
standard: 'large',
|
||||
xlarge: 'xlarge',
|
||||
}),
|
||||
audioSlot: Object.freeze({
|
||||
placement: 'unified-topbar',
|
||||
minimumHeightPx: 48,
|
||||
textModeLabel: '直接看文字',
|
||||
readyLabel: '听一听',
|
||||
playingLabel: '暂停',
|
||||
replayLabel: '重听',
|
||||
usesPageAudioCueIds: true,
|
||||
}),
|
||||
artOverlayPolicy: Object.freeze({
|
||||
event: 'transparent-actor-hotspot-only',
|
||||
emotion: 'transparent-actor-hotspot-only',
|
||||
}),
|
||||
})
|
||||
|
||||
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
|
||||
cover: 'chapter-ensemble',
|
||||
ensemble: 'chapter-ensemble',
|
||||
event: 'actor-event',
|
||||
emotion: 'emotion-interaction',
|
||||
memory: 'memory-cliffhanger',
|
||||
})
|
||||
|
||||
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
|
||||
'S01-C01': 'front-back-comic-v1',
|
||||
'S01-C02': 'front-back-comic-v1',
|
||||
'S01-C03': 'front-back-comic-v1',
|
||||
'S01-C04': 'front-back-comic-v1',
|
||||
'S01-C05': 'front-back-comic-v1',
|
||||
'S01-C06': 'front-back-comic-v1',
|
||||
'S01-C07': 'front-back-comic-v1',
|
||||
'S01-C08': 'front-back-comic-v1',
|
||||
'S01-C09': 'front-back-comic-v1',
|
||||
'S01-C10': 'front-back-comic-v1',
|
||||
'S01-C11': 'front-back-comic-v1',
|
||||
'S01-C12': 'front-back-comic-v1',
|
||||
'S01-C13': 'front-back-comic-v1',
|
||||
'S01-C14': 'front-back-comic-v1',
|
||||
'S01-C15': 'front-back-comic-v1',
|
||||
})
|
||||
|
||||
function getComicPresentationMode(chapterId) {
|
||||
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|
||||
|| 'legacy-60-40-v1'
|
||||
}
|
||||
|
||||
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
|
||||
'S01-C09-P03': Object.freeze({
|
||||
layoutVariant: 'standard-landscape-60-40',
|
||||
behavior: 'key-safe-actor-hotspot',
|
||||
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
|
||||
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
|
||||
}),
|
||||
})
|
||||
|
||||
// These pages keep the full reviewed frame and add one read-only crop from
|
||||
// the same asset as a second comic panel. This is an art-reading aid, not a
|
||||
// new interaction or a page-layout exception.
|
||||
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
|
||||
'S01-C04-P04',
|
||||
'S01-C09-P01',
|
||||
'S01-C09-P05',
|
||||
'S01-C09-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C13-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function allowsFixedDetailInset(pageId) {
|
||||
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
|
||||
String(pageId || ''),
|
||||
) >= 0
|
||||
}
|
||||
|
||||
function getComicPageLayoutException(pageId) {
|
||||
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
|
||||
}
|
||||
|
||||
function resolveComicPageLayout(pageId, type) {
|
||||
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
|
||||
const exception = getComicPageLayoutException(pageId)
|
||||
return {
|
||||
templateKey,
|
||||
layoutVariant: exception
|
||||
? exception.layoutVariant
|
||||
: 'standard-landscape-60-40',
|
||||
layoutExceptionId: exception ? String(pageId) : '',
|
||||
layoutExceptionBehavior: exception ? exception.behavior : '',
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_READER_LAYOUT_CONTRACT,
|
||||
COMIC_PRESENTATION_BY_CHAPTER,
|
||||
COMIC_PAGE_TEMPLATE_BY_TYPE,
|
||||
COMIC_PAGE_LAYOUT_EXCEPTIONS,
|
||||
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
|
||||
allowsFixedDetailInset,
|
||||
getComicPresentationMode,
|
||||
getComicPageLayoutException,
|
||||
resolveComicPageLayout,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
const CACHE_INDEX_VERSION = 2
|
||||
const CACHE_FOLDER = 'tang-detective-assets-v2'
|
||||
const CACHE_INDEX_FILE = 'index.json'
|
||||
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_MAX_ENTRIES = 32
|
||||
|
||||
const SHA256_ROUND_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function utf8Bytes(value) {
|
||||
const encoded = encodeURIComponent(String(value || ''))
|
||||
const bytes = []
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] === '%') {
|
||||
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
|
||||
index += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
if (typeof value === 'string') return utf8Bytes(value)
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
throw new Error('unsupported SHA-256 input')
|
||||
}
|
||||
|
||||
function rotateRight(value, bits) {
|
||||
return (value >>> bits) | (value << (32 - bits))
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
const bytes = toBytes(value)
|
||||
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||
const message = new Uint8Array(paddedLength)
|
||||
message.set(bytes)
|
||||
message[bytes.length] = 0x80
|
||||
const bitLength = bytes.length * 8
|
||||
const highBits = Math.floor(bitLength / 0x100000000)
|
||||
const lowBits = bitLength >>> 0
|
||||
const lengthOffset = paddedLength - 8
|
||||
message[lengthOffset] = (highBits >>> 24) & 0xff
|
||||
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
|
||||
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
|
||||
message[lengthOffset + 3] = highBits & 0xff
|
||||
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
|
||||
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
|
||||
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
|
||||
message[lengthOffset + 7] = lowBits & 0xff
|
||||
|
||||
const hash = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]
|
||||
const words = new Uint32Array(64)
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const start = offset + index * 4
|
||||
words[index] = (
|
||||
(message[start] << 24)
|
||||
| (message[start + 1] << 16)
|
||||
| (message[start + 2] << 8)
|
||||
| message[start + 3]
|
||||
) >>> 0
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const word15 = words[index - 15]
|
||||
const word2 = words[index - 2]
|
||||
const sigma0 = (
|
||||
rotateRight(word15, 7)
|
||||
^ rotateRight(word15, 18)
|
||||
^ (word15 >>> 3)
|
||||
)
|
||||
const sigma1 = (
|
||||
rotateRight(word2, 17)
|
||||
^ rotateRight(word2, 19)
|
||||
^ (word2 >>> 10)
|
||||
)
|
||||
words[index] = (
|
||||
words[index - 16]
|
||||
+ sigma0
|
||||
+ words[index - 7]
|
||||
+ sigma1
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
let a = hash[0]
|
||||
let b = hash[1]
|
||||
let c = hash[2]
|
||||
let d = hash[3]
|
||||
let e = hash[4]
|
||||
let f = hash[5]
|
||||
let g = hash[6]
|
||||
let h = hash[7]
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
||||
const choice = (e & f) ^ (~e & g)
|
||||
const temp1 = (
|
||||
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
|
||||
) >>> 0
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (sum0 + majority) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0
|
||||
hash[1] = (hash[1] + b) >>> 0
|
||||
hash[2] = (hash[2] + c) >>> 0
|
||||
hash[3] = (hash[3] + d) >>> 0
|
||||
hash[4] = (hash[4] + e) >>> 0
|
||||
hash[5] = (hash[5] + f) >>> 0
|
||||
hash[6] = (hash[6] + g) >>> 0
|
||||
hash[7] = (hash[7] + h) >>> 0
|
||||
}
|
||||
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||
}
|
||||
|
||||
function constantTimeEqualHex(left, right) {
|
||||
const leftValue = String(left || '').toLowerCase()
|
||||
const rightValue = String(right || '').toLowerCase()
|
||||
const length = Math.max(leftValue.length, rightValue.length)
|
||||
let difference = leftValue.length ^ rightValue.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
|
||||
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
|
||||
difference |= leftCode ^ rightCode
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function joinPath(left, right) {
|
||||
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function normalizeIndex(value) {
|
||||
if (
|
||||
!value
|
||||
|| value.version !== CACHE_INDEX_VERSION
|
||||
|| !value.entries
|
||||
|| typeof value.entries !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: CACHE_INDEX_VERSION,
|
||||
entries: {},
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function callFs(fs, method, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs || typeof fs[method] !== 'function') {
|
||||
reject(new Error(`fs.${method} unavailable`))
|
||||
return
|
||||
}
|
||||
fs[method]({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createAssetPlatformFacade() {
|
||||
if (typeof wx === 'undefined') return null
|
||||
const env = Object.freeze({
|
||||
USER_DATA_PATH: String(
|
||||
wx.env && wx.env.USER_DATA_PATH || '',
|
||||
),
|
||||
})
|
||||
return Object.freeze({
|
||||
downloadFile(options) {
|
||||
if (typeof wx.downloadFile !== 'function') {
|
||||
if (options && typeof options.fail === 'function') {
|
||||
options.fail(new Error('wx.downloadFile unavailable'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return wx.downloadFile(options)
|
||||
},
|
||||
getFileSystemManager() {
|
||||
if (typeof wx.getFileSystemManager !== 'function') return null
|
||||
return wx.getFileSystemManager()
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
function download(assetPlatform, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
|
||||
reject(new Error('wx.downloadFile unavailable'))
|
||||
return
|
||||
}
|
||||
assetPlatform.downloadFile({
|
||||
url,
|
||||
success(result) {
|
||||
if (
|
||||
result
|
||||
&& result.statusCode >= 200
|
||||
&& result.statusCode < 300
|
||||
&& result.tempFilePath
|
||||
) {
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
reject(new Error(`download status ${result && result.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function safeCacheName(assetId, asset) {
|
||||
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
|
||||
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
|
||||
}
|
||||
|
||||
function createAssetManager(options = {}) {
|
||||
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
|
||||
const manifest = options.manifest || {}
|
||||
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
|
||||
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
|
||||
? Math.max(0, options.imageCacheBudgetBytes)
|
||||
: DEFAULT_IMAGE_BUDGET
|
||||
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
|
||||
? Math.max(0, options.audioCacheBudgetBytes)
|
||||
: DEFAULT_AUDIO_BUDGET
|
||||
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
|
||||
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
|
||||
: DEFAULT_AUDIO_MAX_ENTRIES
|
||||
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
|
||||
? Math.max(1, Math.floor(options.downloadConcurrency))
|
||||
: 2
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now
|
||||
const fs = assetPlatform
|
||||
&& typeof assetPlatform.getFileSystemManager === 'function'
|
||||
? assetPlatform.getFileSystemManager()
|
||||
: null
|
||||
const userDataPath = (
|
||||
assetPlatform
|
||||
&& assetPlatform.env
|
||||
&& assetPlatform.env.USER_DATA_PATH
|
||||
) || ''
|
||||
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
|
||||
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
|
||||
|
||||
let initialized = false
|
||||
let index = normalizeIndex(null)
|
||||
let activeDownloads = 0
|
||||
const inflight = new Map()
|
||||
const downloadWaiters = []
|
||||
|
||||
function fallback(assetId, reason) {
|
||||
return {
|
||||
assetId,
|
||||
available: false,
|
||||
uri: '',
|
||||
source: 'text-fallback',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCacheFolder() {
|
||||
if (!cacheRoot) return false
|
||||
try {
|
||||
await callFs(fs, 'mkdir', {
|
||||
dirPath: cacheRoot,
|
||||
recursive: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// EEXIST and older base-library mkdir failures are both safe to ignore.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function persistIndex() {
|
||||
if (!indexPath) return
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
await callFs(fs, 'writeFile', {
|
||||
filePath: indexPath,
|
||||
data: JSON.stringify(index),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
} catch (error) {
|
||||
// Cache metadata must never block the text-first game.
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
await callFs(fs, 'access', { path: filePath })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMatchesSha256(filePath, expectedSha256) {
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', { filePath })
|
||||
return constantTimeEqualHex(
|
||||
sha256Hex(result.data),
|
||||
expectedSha256,
|
||||
)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (!indexPath) return
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', {
|
||||
filePath: indexPath,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
index = normalizeIndex(JSON.parse(result.data))
|
||||
} catch (error) {
|
||||
index = normalizeIndex(null)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [assetId, entry] of Object.entries(index.entries)) {
|
||||
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await persistIndex()
|
||||
await enforceImageBudget()
|
||||
await enforceAudioBudget()
|
||||
}
|
||||
|
||||
function cacheBytes(kind) {
|
||||
return Object.values(index.entries).reduce(
|
||||
(total, entry) => (
|
||||
entry.kind === kind
|
||||
? total + Math.max(0, Number(entry.size) || 0)
|
||||
: total
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function cacheEntries(kind) {
|
||||
return Object.values(index.entries)
|
||||
.filter((entry) => entry.kind === kind)
|
||||
.length
|
||||
}
|
||||
|
||||
function imageCacheBytes() {
|
||||
return cacheBytes('image')
|
||||
}
|
||||
|
||||
function audioCacheBytes() {
|
||||
return cacheBytes('audio')
|
||||
}
|
||||
|
||||
async function unlinkQuietly(filePath) {
|
||||
try {
|
||||
await callFs(fs, 'unlink', { filePath })
|
||||
} catch (error) {
|
||||
// A missing cache file is already evicted.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceImageBudget(protectedAssetId = '') {
|
||||
let total = imageCacheBytes()
|
||||
if (total <= imageBudgetBytes) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'image' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= imageBudgetBytes) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function enforceAudioBudget(protectedAssetId = '') {
|
||||
let total = audioCacheBytes()
|
||||
let entries = cacheEntries('audio')
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'audio' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
entries -= 1
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function resolveCached(assetId, asset) {
|
||||
const entry = index.entries[assetId]
|
||||
if (!entry || entry.sha256 !== asset.sha256) return null
|
||||
if (!(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return null
|
||||
}
|
||||
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
|
||||
await unlinkQuietly(entry.filePath)
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
entry.lastAccessedAt = now()
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: entry.filePath,
|
||||
source: 'cache',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTempFileSize(tempFilePath, response) {
|
||||
if (Number.isFinite(response.fileSize)) return response.fileSize
|
||||
try {
|
||||
const statResult = await callFs(fs, 'stat', {
|
||||
path: tempFilePath,
|
||||
})
|
||||
const stat = statResult && statResult.stats
|
||||
return Math.max(0, Number(stat && stat.size) || 0)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(assetId, asset) {
|
||||
if (!cdnBaseUrl || !asset.remotePath) {
|
||||
return fallback(assetId, 'remote-disabled')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
activeDownloads += 1
|
||||
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1
|
||||
const next = downloadWaiters.shift()
|
||||
if (next) next()
|
||||
})
|
||||
}
|
||||
if (activeDownloads < downloadConcurrency) run()
|
||||
else downloadWaiters.push(run)
|
||||
})
|
||||
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
|
||||
await unlinkQuietly(response.tempFilePath)
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
const size = await getTempFileSize(response.tempFilePath, response)
|
||||
|
||||
const exceedsCachePolicy = (
|
||||
(asset.kind === 'image' && size > imageBudgetBytes)
|
||||
|| (
|
||||
asset.kind === 'audio'
|
||||
&& (size > audioBudgetBytes || audioMaxEntries < 1)
|
||||
)
|
||||
)
|
||||
if (!cacheRoot || exceedsCachePolicy) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
|
||||
await callFs(fs, 'saveFile', {
|
||||
tempFilePath: response.tempFilePath,
|
||||
filePath,
|
||||
})
|
||||
index.entries[assetId] = {
|
||||
assetId,
|
||||
filePath,
|
||||
kind: asset.kind,
|
||||
sha256: asset.sha256,
|
||||
size,
|
||||
lastAccessedAt: now(),
|
||||
}
|
||||
await enforceImageBudget(assetId)
|
||||
await enforceAudioBudget(assetId)
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: filePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(assetId, resolveOptions = {}) {
|
||||
await init()
|
||||
const asset = manifest[assetId]
|
||||
if (!asset) return fallback(assetId, 'unknown-asset')
|
||||
if (
|
||||
asset.kind === 'audio'
|
||||
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
|
||||
) {
|
||||
return fallback(assetId, 'audio-unapproved')
|
||||
}
|
||||
|
||||
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
|
||||
if (asset.localSeed && resolveOptions.preferRemote !== true) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
const cached = await resolveCached(assetId, asset)
|
||||
if (cached) return cached
|
||||
|
||||
if (resolveOptions.allowRemote === false) {
|
||||
if (asset.localSeed) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
return fallback(assetId, 'remote-disallowed')
|
||||
}
|
||||
|
||||
if (inflight.has(assetId)) return inflight.get(assetId)
|
||||
|
||||
const request = fetchRemote(assetId, asset)
|
||||
.catch(() => (
|
||||
asset.localSeed
|
||||
? {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
: fallback(assetId, 'remote-failed')
|
||||
))
|
||||
.finally(() => inflight.delete(assetId))
|
||||
inflight.set(assetId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function prefetch(assetIds) {
|
||||
const ids = Array.isArray(assetIds) ? assetIds : []
|
||||
return Promise.all(ids.map((assetId) => resolve(assetId)))
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await init()
|
||||
const entries = Object.values(index.entries)
|
||||
for (const entry of entries) await unlinkQuietly(entry.filePath)
|
||||
index = normalizeIndex(null)
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return {
|
||||
entries: Object.keys(index.entries).length,
|
||||
imageBytes: imageCacheBytes(),
|
||||
imageBudgetBytes,
|
||||
audioEntries: cacheEntries('audio'),
|
||||
audioBytes: audioCacheBytes(),
|
||||
audioBudgetBytes,
|
||||
audioMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
resolve,
|
||||
prefetch,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
getAsset(assetId) {
|
||||
return manifest[assetId] || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_INDEX_VERSION,
|
||||
DEFAULT_AUDIO_BUDGET,
|
||||
DEFAULT_AUDIO_MAX_ENTRIES,
|
||||
constantTimeEqualHex,
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
sha256Hex,
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
const REVIEWED_AUDIO_STATUS = 'approved'
|
||||
const REVIEWED_VISUAL_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_STATUS = 'experience-provisional'
|
||||
const PROVISIONAL_VISUAL_TIER = 'experience-provisional'
|
||||
const IMAGE_UNAVAILABLE_MESSAGE = '本页画面暂时没有打开,文字和互动仍可继续。'
|
||||
const AUDIO_FILE_PATTERN = /\.(?:aac|m4a|mp3|ogg|wav)$/i
|
||||
|
||||
function cleanPath(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isPlayableVisualDeclaration(visual) {
|
||||
if (!visual || typeof visual !== 'object') return false
|
||||
const reviewStatus = cleanPath(visual.reviewStatus).toLowerCase()
|
||||
if (reviewStatus === REVIEWED_VISUAL_STATUS) return true
|
||||
return Boolean(
|
||||
visual.kind === 'page-art'
|
||||
&& cleanPath(visual.runtimeTier) === PROVISIONAL_VISUAL_TIER
|
||||
&& visual.formalReleaseEligible === false
|
||||
&& reviewStatus === PROVISIONAL_VISUAL_STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
function isPackagedPath(value) {
|
||||
const path = cleanPath(value)
|
||||
return (
|
||||
path.startsWith('/assets/')
|
||||
|| /^\/package-[a-z0-9-]+\//i.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
function actorSheetFocusPercent(person) {
|
||||
const era = String(person && person.eraLabel || '')
|
||||
const characterId = String(person && person.characterId || '')
|
||||
if (characterId === 'qin-xiaoman') {
|
||||
if (/2008/.test(era)) return 16
|
||||
if (/2026/.test(era)) return 65
|
||||
}
|
||||
if (characterId === 'tang-mingyuan') {
|
||||
if (/1995/.test(era)) return 11
|
||||
if (/2001/.test(era)) return 37
|
||||
if (/2003/.test(era)) return 63
|
||||
if (/2026/.test(era)) return 88
|
||||
}
|
||||
if (/1978|1980/.test(era)) return 11
|
||||
if (/1993|1995|1998/.test(era)) return 31
|
||||
if (/2001|2003/.test(era)) return 50
|
||||
if (/2008/.test(era)) return 69
|
||||
if (/2026/.test(era)) return 88
|
||||
return 50
|
||||
}
|
||||
|
||||
function isSinglePortraitFallback(person) {
|
||||
return String(person && person.characterId || '') === 'female-cook'
|
||||
}
|
||||
|
||||
function getComicPageImageMode(person, usingActorFallback) {
|
||||
return usingActorFallback && isSinglePortraitFallback(person)
|
||||
? 'aspectFit'
|
||||
: 'scaleToFill'
|
||||
}
|
||||
|
||||
function getComicPageImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const focusX = actorSheetFocusPercent(person)
|
||||
const translateX = 50 - (3.5 * focusX)
|
||||
return [
|
||||
'transform-origin:0 0',
|
||||
`transform:translate(${translateX}%,-30%) scale(3.5)`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
function getComicFocusImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const sourceFocusX = actorSheetFocusPercent(person)
|
||||
const focusX = 25 + (sourceFocusX * 0.5)
|
||||
return [
|
||||
'transform:scale(7)',
|
||||
`transform-origin:${focusX}% 12%`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first image shown by a comic page.
|
||||
*
|
||||
* A release-manifest localSeed is authoritative and must never wait for the
|
||||
* network. A remote-only illustration starts on the reviewed scene fallback,
|
||||
* so a slow or failed download cannot leave a blank reader.
|
||||
*/
|
||||
function buildComicImageState(page = {}, releaseAsset = null, previous = {}) {
|
||||
const illustrationAsset = cleanPath(page.illustrationAsset)
|
||||
const fallbackAsset = cleanPath(page.fallbackAsset)
|
||||
const playableVisual = isPlayableVisualDeclaration(page.playableVisual)
|
||||
? page.playableVisual
|
||||
: null
|
||||
const sharedVisualKind = playableVisual
|
||||
&& playableVisual.kind !== 'page-art'
|
||||
? cleanPath(playableVisual.kind)
|
||||
: ''
|
||||
const sharedVisualAsset = sharedVisualKind
|
||||
? cleanPath(playableVisual.asset)
|
||||
: ''
|
||||
const actorFallbackAsset = cleanPath(
|
||||
(
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
? sharedVisualAsset
|
||||
: (page.actorFallbackAsset || page.actorPortrait),
|
||||
)
|
||||
const localSeed = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.localSeed)
|
||||
: ''
|
||||
const remotePath = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.remotePath)
|
||||
: ''
|
||||
const keepFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& fallbackAsset
|
||||
)
|
||||
const keepActorFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& previous.comicImageSource === 'actor-fallback'
|
||||
&& actorFallbackAsset
|
||||
)
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& (
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: sharedVisualAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'shared-portrait',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& sharedVisualKind === 'evidence-composite'
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'shared-evidence',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepActorFallback) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepFallback) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (localSeed) {
|
||||
return {
|
||||
src: localSeed,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'local-seed',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (remotePath) {
|
||||
const loadingFallback = actorFallbackAsset || fallbackAsset
|
||||
const usingActorFallback = Boolean(actorFallbackAsset)
|
||||
return {
|
||||
src: loadingFallback,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: Boolean(loadingFallback),
|
||||
usingActorFallback,
|
||||
source: usingActorFallback
|
||||
? 'actor-fallback-loading'
|
||||
: (fallbackAsset ? 'fallback-loading' : 'text-fallback-loading'),
|
||||
error: loadingFallback ? '' : IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseAsset && illustrationAsset) {
|
||||
return {
|
||||
src: illustrationAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'declared-local-path',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (actorFallbackAsset) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
src: '',
|
||||
fallback: '',
|
||||
actorFallback: '',
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'text-fallback',
|
||||
error: IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio is intentionally fail-closed. A non-empty src alone is not enough:
|
||||
* both the story data and immutable release manifest must mark the file as
|
||||
* approved, and the playable src must be the packaged, hash-checked seed.
|
||||
*/
|
||||
function getReviewedAudioSrc(media = {}, manifest = {}) {
|
||||
const status = cleanPath(media.status || media.audioStatus).toLowerCase()
|
||||
const assetId = cleanPath(media.assetId || media.audioAssetId)
|
||||
const declaredSrc = cleanPath(media.src || media.audioSrc)
|
||||
if (
|
||||
status !== REVIEWED_AUDIO_STATUS
|
||||
|| !assetId
|
||||
|| !declaredSrc
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const releaseAsset = manifest[assetId]
|
||||
if (
|
||||
!releaseAsset
|
||||
|| releaseAsset.kind !== 'audio'
|
||||
|| cleanPath(releaseAsset.reviewStatus).toLowerCase()
|
||||
!== REVIEWED_AUDIO_STATUS
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const localSeed = cleanPath(releaseAsset.localSeed)
|
||||
if (
|
||||
!localSeed
|
||||
|| localSeed !== declaredSrc
|
||||
|| !isPackagedPath(localSeed)
|
||||
|| !AUDIO_FILE_PATTERN.test(localSeed)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return localSeed
|
||||
}
|
||||
|
||||
function buildReviewedAudioState(media = {}, manifest = {}) {
|
||||
const src = getReviewedAudioSrc(media, manifest)
|
||||
return {
|
||||
available: Boolean(src),
|
||||
src,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDIO_FILE_PATTERN,
|
||||
IMAGE_UNAVAILABLE_MESSAGE,
|
||||
REVIEWED_AUDIO_STATUS,
|
||||
buildComicImageState,
|
||||
buildReviewedAudioState,
|
||||
getComicFocusImageStyle,
|
||||
getComicPageImageMode,
|
||||
getComicPageImageStyle,
|
||||
getReviewedAudioSrc,
|
||||
isPackagedPath,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
const COMIC_PAGE_TYPES = Object.freeze([
|
||||
'cover',
|
||||
'ensemble',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'emotion',
|
||||
'memory',
|
||||
])
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicPages(model = {}) {
|
||||
return Array.isArray(model.pageSequence) ? model.pageSequence : []
|
||||
}
|
||||
|
||||
function pageIndex(model, pageId) {
|
||||
const requested = cleanString(pageId)
|
||||
if (!requested) return -1
|
||||
return comicPages(model).findIndex((page) => page.pageId === requested)
|
||||
}
|
||||
|
||||
function eventPages(model) {
|
||||
return comicPages(model).filter((page) => page.type === 'event')
|
||||
}
|
||||
|
||||
function isEightPageComicModel(model = {}) {
|
||||
const pages = comicPages(model)
|
||||
if (
|
||||
model.mode !== 'comic'
|
||||
|| !cleanString(model.chapterId)
|
||||
|| pages.length !== COMIC_PAGE_TYPES.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return pages.every((page, index) => (
|
||||
page
|
||||
&& page.pageId === `${model.chapterId}-P${String(index + 1).padStart(2, '0')}`
|
||||
&& page.type === COMIC_PAGE_TYPES[index]
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a contiguous prefix of P03-P06 is trusted.
|
||||
*
|
||||
* This prevents stale, foreign, duplicated or out-of-order ids from opening a
|
||||
* later page. It also keeps the locked 60-event source order unchanged.
|
||||
*/
|
||||
function normalizeCompletedEventIds(model, completedIds = []) {
|
||||
const supplied = new Set(
|
||||
(Array.isArray(completedIds) ? completedIds : [])
|
||||
.map(cleanString)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const normalized = []
|
||||
for (const page of eventPages(model)) {
|
||||
if (!supplied.has(page.eventId)) break
|
||||
normalized.push(page.eventId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getUnlockedPageIndex(
|
||||
model,
|
||||
completedIds = [],
|
||||
chapterFinished = false,
|
||||
) {
|
||||
const pages = comicPages(model)
|
||||
if (!pages.length) return -1
|
||||
if (!isEightPageComicModel(model)) return 0
|
||||
if (chapterFinished) return pages.length - 1
|
||||
|
||||
const completed = normalizeCompletedEventIds(model, completedIds)
|
||||
const firstEventIndex = pages.findIndex((page) => page.type === 'event')
|
||||
const emotionIndex = pages.findIndex((page) => page.type === 'emotion')
|
||||
if (firstEventIndex < 0) return 0
|
||||
|
||||
return Math.min(
|
||||
emotionIndex >= 0 ? emotionIndex : pages.length - 1,
|
||||
firstEventIndex + completed.length,
|
||||
)
|
||||
}
|
||||
|
||||
function emptyReaderState() {
|
||||
return {
|
||||
valid: false,
|
||||
currentPage: null,
|
||||
currentPageId: '',
|
||||
currentPageIndex: -1,
|
||||
unlockedPageIndex: -1,
|
||||
unlockedPageIds: [],
|
||||
completedEventIds: [],
|
||||
allEventsComplete: false,
|
||||
chapterFinished: false,
|
||||
canGoPrevious: false,
|
||||
canGoNext: false,
|
||||
nextPageLocked: false,
|
||||
activeInteraction: null,
|
||||
activeHotspots: [],
|
||||
pageAccess: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildComicReaderState(model, progress = {}) {
|
||||
if (!isEightPageComicModel(model)) return emptyReaderState()
|
||||
|
||||
const pages = comicPages(model)
|
||||
const completedEventIds = normalizeCompletedEventIds(
|
||||
model,
|
||||
progress.completedEventIds || progress.completedIds,
|
||||
)
|
||||
const allEventsComplete = (
|
||||
completedEventIds.length === eventPages(model).length
|
||||
)
|
||||
const chapterFinished = Boolean(
|
||||
progress.chapterFinished && allEventsComplete,
|
||||
)
|
||||
const unlockedPageIndex = getUnlockedPageIndex(
|
||||
model,
|
||||
completedEventIds,
|
||||
chapterFinished,
|
||||
)
|
||||
const requestedPageId = (
|
||||
cleanString(progress.currentPageId)
|
||||
|| cleanString(progress.savedPageId)
|
||||
|| model.firstPageId
|
||||
|| pages[0].pageId
|
||||
)
|
||||
const requestedIndex = pageIndex(model, requestedPageId)
|
||||
const currentPageIndex = Math.min(
|
||||
unlockedPageIndex,
|
||||
Math.max(0, requestedIndex >= 0 ? requestedIndex : 0),
|
||||
)
|
||||
const currentPage = pages[currentPageIndex]
|
||||
const completed = new Set(completedEventIds)
|
||||
|
||||
let activeInteraction = null
|
||||
if (
|
||||
currentPage.type === 'event'
|
||||
&& !completed.has(currentPage.eventId)
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'event',
|
||||
id: currentPage.eventId,
|
||||
eventId: currentPage.eventId,
|
||||
pageId: currentPage.pageId,
|
||||
actorInstanceId: currentPage.actorInstanceId || '',
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
} else if (
|
||||
currentPage.type === 'emotion'
|
||||
&& allEventsComplete
|
||||
&& !chapterFinished
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'emotion',
|
||||
id: currentPage.emotionMomentId,
|
||||
emotionMomentId: currentPage.emotionMomentId,
|
||||
pageId: currentPage.pageId,
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
}
|
||||
|
||||
const activeHotspots = (
|
||||
activeInteraction
|
||||
&& activeInteraction.actorHotspot
|
||||
)
|
||||
? [{
|
||||
interactionId: activeInteraction.id,
|
||||
pageId: activeInteraction.pageId,
|
||||
actorHotspot: activeInteraction.actorHotspot,
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
currentPage,
|
||||
currentPageId: currentPage.pageId,
|
||||
currentPageIndex,
|
||||
unlockedPageIndex,
|
||||
unlockedPageIds: pages
|
||||
.slice(0, unlockedPageIndex + 1)
|
||||
.map((page) => page.pageId),
|
||||
completedEventIds,
|
||||
allEventsComplete,
|
||||
chapterFinished,
|
||||
canGoPrevious: currentPageIndex > 0,
|
||||
canGoNext: currentPageIndex < unlockedPageIndex,
|
||||
nextPageLocked: (
|
||||
currentPageIndex < pages.length - 1
|
||||
&& currentPageIndex >= unlockedPageIndex
|
||||
),
|
||||
activeInteraction,
|
||||
activeHotspots,
|
||||
pageAccess: pages.map((page, index) => ({
|
||||
pageId: page.pageId,
|
||||
pageIndex: index,
|
||||
type: page.type,
|
||||
unlocked: index <= unlockedPageIndex,
|
||||
current: index === currentPageIndex,
|
||||
interactionEnabled: Boolean(
|
||||
activeInteraction
|
||||
&& activeInteraction.pageId === page.pageId
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function progressFromState(state) {
|
||||
return {
|
||||
currentPageId: state.currentPageId,
|
||||
completedEventIds: [...state.completedEventIds],
|
||||
chapterFinished: state.chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one chapter from the shared v1 storage object.
|
||||
*
|
||||
* `comicReaderByChapter` is the canonical reader cursor. The older
|
||||
* completedHotspots/completedChapters/lastPageId fields remain mirrored for
|
||||
* catalog compatibility and safe migration from already-installed builds.
|
||||
*/
|
||||
function readComicReaderProgress(storageProgress = {}, model = {}, chapterNumber) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
const storedByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? storageProgress.comicReaderByChapter
|
||||
: {}
|
||||
const storedChapter = (
|
||||
chapterId
|
||||
&& storedByChapter[chapterId]
|
||||
&& typeof storedByChapter[chapterId] === 'object'
|
||||
)
|
||||
? storedByChapter[chapterId]
|
||||
: {}
|
||||
const legacyHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
&& Array.isArray(storageProgress.completedHotspots[chapterId])
|
||||
)
|
||||
? storageProgress.completedHotspots[chapterId]
|
||||
: []
|
||||
const legacyFinished = (
|
||||
Array.isArray(storageProgress.completedChapters)
|
||||
&& storageProgress.completedChapters.includes(chapterId)
|
||||
)
|
||||
const chapterFinished = (
|
||||
typeof storedChapter.chapterFinished === 'boolean'
|
||||
? storedChapter.chapterFinished
|
||||
: legacyFinished
|
||||
)
|
||||
const isLastChapter = (
|
||||
Number(storageProgress.lastChapter) === Number(chapterNumber)
|
||||
)
|
||||
|
||||
return {
|
||||
currentPageId: (
|
||||
cleanString(storedChapter.currentPageId)
|
||||
|| (isLastChapter ? cleanString(storageProgress.lastPageId) : '')
|
||||
|| (chapterFinished ? cleanString(model.lastPageId) : '')
|
||||
|| cleanString(model.firstPageId)
|
||||
),
|
||||
completedEventIds: Array.isArray(storedChapter.completedEventIds)
|
||||
? [...storedChapter.completedEventIds]
|
||||
: [...legacyHotspots],
|
||||
chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge only the current chapter back into shared storage.
|
||||
*
|
||||
* Other chapters and unrelated product fields are copied through untouched.
|
||||
* The supplied reader progress is normalized again here so stale `finished`
|
||||
* flags and out-of-order future event ids cannot be persisted.
|
||||
*/
|
||||
function mergeComicReaderProgress(
|
||||
storageProgress = {},
|
||||
model = {},
|
||||
chapterNumber,
|
||||
readerProgress = {},
|
||||
) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
if (!chapterId || !isEightPageComicModel(model)) {
|
||||
return { ...storageProgress }
|
||||
}
|
||||
const state = buildComicReaderState(model, readerProgress)
|
||||
const normalized = progressFromState(state)
|
||||
const completedHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
)
|
||||
? { ...storageProgress.completedHotspots }
|
||||
: {}
|
||||
const comicReaderByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? { ...storageProgress.comicReaderByChapter }
|
||||
: {}
|
||||
const completedChapters = Array.isArray(storageProgress.completedChapters)
|
||||
? storageProgress.completedChapters.filter(
|
||||
(storedChapterId) => storedChapterId !== chapterId,
|
||||
)
|
||||
: []
|
||||
|
||||
completedHotspots[chapterId] = [...normalized.completedEventIds]
|
||||
comicReaderByChapter[chapterId] = normalized
|
||||
if (normalized.chapterFinished) completedChapters.push(chapterId)
|
||||
|
||||
return {
|
||||
...storageProgress,
|
||||
completedHotspots,
|
||||
completedChapters,
|
||||
comicReaderByChapter,
|
||||
lastChapter: Number(chapterNumber) || model.chapterNumber || 1,
|
||||
lastPageId: normalized.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transition helper. Completing an interaction unlocks the following
|
||||
* page but never turns it automatically, so the reader still controls the
|
||||
* lianhuanhua rhythm.
|
||||
*/
|
||||
function applyComicReaderAction(model, progress = {}, action = {}) {
|
||||
const state = buildComicReaderState(model, progress)
|
||||
if (!state.valid) return progressFromState(state)
|
||||
const type = cleanString(action.type)
|
||||
let nextProgress = progressFromState(state)
|
||||
|
||||
if (type === 'previous-page' && state.canGoPrevious) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex - 1
|
||||
].pageId
|
||||
} else if (type === 'next-page' && state.canGoNext) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex + 1
|
||||
].pageId
|
||||
} else if (type === 'open-page') {
|
||||
const targetIndex = pageIndex(model, action.pageId)
|
||||
if (targetIndex >= 0 && targetIndex <= state.unlockedPageIndex) {
|
||||
nextProgress.currentPageId = comicPages(model)[targetIndex].pageId
|
||||
}
|
||||
} else if (
|
||||
type === 'complete-event'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'event'
|
||||
&& state.activeInteraction.eventId === cleanString(action.eventId)
|
||||
) {
|
||||
nextProgress.completedEventIds = [
|
||||
...state.completedEventIds,
|
||||
state.activeInteraction.eventId,
|
||||
]
|
||||
} else if (
|
||||
type === 'complete-emotion'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'emotion'
|
||||
&& state.activeInteraction.emotionMomentId
|
||||
=== cleanString(action.emotionMomentId)
|
||||
) {
|
||||
nextProgress.chapterFinished = true
|
||||
}
|
||||
|
||||
return progressFromState(buildComicReaderState(model, nextProgress))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared illustration path is not proof that a finished page exists.
|
||||
* Callers must explicitly confirm formalAvailable after package/manifest
|
||||
* validation. Otherwise the state is visibly a fallback, never "formal art".
|
||||
*/
|
||||
function buildComicArtAvailability(page = {}, options = {}) {
|
||||
const formalAsset = cleanString(
|
||||
options.formalAsset || page.illustrationAsset,
|
||||
)
|
||||
const fallbackAsset = cleanString(
|
||||
options.fallbackAsset || page.fallbackAsset,
|
||||
)
|
||||
if (options.formalAvailable === true && formalAsset) {
|
||||
return {
|
||||
mode: 'formal-art',
|
||||
src: formalAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: true,
|
||||
isFallback: false,
|
||||
}
|
||||
}
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
mode: 'scene-fallback',
|
||||
src: fallbackAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'text-only',
|
||||
src: '',
|
||||
formalAsset,
|
||||
fallbackAsset: '',
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_PAGE_TYPES,
|
||||
applyComicReaderAction,
|
||||
buildComicArtAvailability,
|
||||
buildComicReaderState,
|
||||
getUnlockedPageIndex,
|
||||
isEightPageComicModel,
|
||||
mergeComicReaderProgress,
|
||||
normalizeCompletedEventIds,
|
||||
pageIndex,
|
||||
readComicReaderProgress,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 发布环境可覆盖这些值。
|
||||
*
|
||||
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
|
||||
* 使用包内种子图或返回文字回退,不会发起网络请求。
|
||||
*/
|
||||
module.exports = {
|
||||
cdnBaseUrl: '',
|
||||
imageCacheBudgetBytes: 28 * 1024 * 1024,
|
||||
audioCacheBudgetBytes: 32 * 1024 * 1024,
|
||||
audioCacheMaxEntries: 32,
|
||||
prefetchAhead: 2,
|
||||
downloadConcurrency: 2,
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
const { releaseAssets } = require('./releaseAssetManifest')
|
||||
|
||||
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
|
||||
const RUNTIME_VISUAL_TIERS = Object.freeze({
|
||||
FORMAL: 'formal',
|
||||
PROVISIONAL: 'experience-provisional',
|
||||
FALLBACK: 'fallback',
|
||||
})
|
||||
|
||||
// This set deliberately mirrors `usable` in
|
||||
// docs/production/art-release-gate-s01.json. Registered page art outside this
|
||||
// set may be shown only as experience-provisional and never counts as formal.
|
||||
const FORMAL_PAGE_ART_IDS = new Set([
|
||||
'S01-C01-P01',
|
||||
'S01-C01-P02',
|
||||
'S01-C01-P03',
|
||||
'S01-C01-P04',
|
||||
'S01-C01-P05',
|
||||
'S01-C01-P07',
|
||||
'S01-C01-P08',
|
||||
'S01-C02-P01',
|
||||
'S01-C02-P02',
|
||||
'S01-C02-P03',
|
||||
'S01-C02-P04',
|
||||
'S01-C02-P05',
|
||||
'S01-C02-P06',
|
||||
'S01-C02-P07',
|
||||
'S01-C02-P08',
|
||||
'S01-C03-P01',
|
||||
'S01-C03-P02',
|
||||
'S01-C03-P03',
|
||||
'S01-C03-P04',
|
||||
'S01-C03-P05',
|
||||
'S01-C03-P07',
|
||||
'S01-C03-P08',
|
||||
'S01-C04-P01',
|
||||
'S01-C04-P02',
|
||||
'S01-C04-P03',
|
||||
'S01-C04-P05',
|
||||
'S01-C04-P06',
|
||||
'S01-C04-P07',
|
||||
'S01-C04-P08',
|
||||
'S01-C05-P01',
|
||||
'S01-C05-P02',
|
||||
'S01-C05-P03',
|
||||
'S01-C05-P04',
|
||||
'S01-C05-P05',
|
||||
'S01-C05-P06',
|
||||
'S01-C05-P07',
|
||||
'S01-C05-P08',
|
||||
'S01-C06-P01',
|
||||
'S01-C06-P02',
|
||||
'S01-C06-P03',
|
||||
'S01-C06-P04',
|
||||
'S01-C06-P05',
|
||||
'S01-C06-P06',
|
||||
'S01-C06-P07',
|
||||
'S01-C06-P08',
|
||||
'S01-C07-P01',
|
||||
'S01-C07-P02',
|
||||
'S01-C07-P03',
|
||||
'S01-C07-P04',
|
||||
'S01-C07-P05',
|
||||
'S01-C07-P06',
|
||||
'S01-C07-P07',
|
||||
'S01-C07-P08',
|
||||
'S01-C08-P02',
|
||||
'S01-C08-P03',
|
||||
'S01-C08-P04',
|
||||
'S01-C08-P05',
|
||||
'S01-C08-P06',
|
||||
'S01-C08-P07',
|
||||
'S01-C08-P08',
|
||||
'S01-C09-P02',
|
||||
'S01-C09-P03',
|
||||
'S01-C09-P04',
|
||||
'S01-C09-P07',
|
||||
'S01-C09-P08',
|
||||
'S01-C10-P01',
|
||||
'S01-C10-P02',
|
||||
'S01-C10-P03',
|
||||
'S01-C10-P04',
|
||||
'S01-C10-P05',
|
||||
'S01-C10-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C10-P08',
|
||||
'S01-C11-P01',
|
||||
'S01-C11-P02',
|
||||
'S01-C11-P03',
|
||||
'S01-C11-P04',
|
||||
'S01-C11-P05',
|
||||
'S01-C11-P06',
|
||||
'S01-C11-P07',
|
||||
'S01-C11-P08',
|
||||
'S01-C12-P01',
|
||||
'S01-C12-P02',
|
||||
'S01-C12-P03',
|
||||
'S01-C12-P04',
|
||||
'S01-C12-P05',
|
||||
'S01-C12-P06',
|
||||
'S01-C12-P07',
|
||||
'S01-C12-P08',
|
||||
'S01-C13-P01',
|
||||
'S01-C13-P02',
|
||||
'S01-C13-P03',
|
||||
'S01-C13-P05',
|
||||
'S01-C13-P06',
|
||||
'S01-C13-P07',
|
||||
'S01-C13-P08',
|
||||
'S01-C14-P01',
|
||||
'S01-C14-P02',
|
||||
'S01-C14-P03',
|
||||
'S01-C14-P04',
|
||||
'S01-C14-P05',
|
||||
'S01-C14-P06',
|
||||
'S01-C14-P07',
|
||||
'S01-C14-P08',
|
||||
'S01-C15-P01',
|
||||
'S01-C15-P02',
|
||||
'S01-C15-P03',
|
||||
'S01-C15-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P06',
|
||||
'S01-C15-P07',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicAssetIdForPageId(pageId) {
|
||||
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
|
||||
if (!match) return ''
|
||||
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
|
||||
}
|
||||
|
||||
function registeredPageArtFor(page) {
|
||||
const assetId = comicAssetIdForPageId(page && page.pageId)
|
||||
const releaseAsset = assetId ? releaseAssets[assetId] : null
|
||||
return Boolean(
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
&& clean(releaseAsset.localSeed)
|
||||
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
|
||||
)
|
||||
}
|
||||
|
||||
function createPageArtVisual(page, chapter, runtimeTier) {
|
||||
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
|
||||
return {
|
||||
kind: 'page-art',
|
||||
asset: clean(page.illustrationAsset),
|
||||
subject: clean(page.actorName || page.headline || chapter.title),
|
||||
runtimeTier,
|
||||
formalReleaseEligible: formal,
|
||||
reviewStatus: formal
|
||||
? PLAYABLE_VISUAL_REVIEW_STATUS
|
||||
: PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: formal
|
||||
? 'art-release-gate-s01-usable'
|
||||
: 'registered-immutable-asset-experience-only',
|
||||
}
|
||||
}
|
||||
|
||||
function personByInstanceId(chapter, instanceId) {
|
||||
return (chapter.people || []).find(
|
||||
(person) => (
|
||||
person.instanceId === instanceId
|
||||
&& clean(person.portrait)
|
||||
),
|
||||
) || null
|
||||
}
|
||||
|
||||
function personByName(chapter, name) {
|
||||
const cleanName = clean(name)
|
||||
if (!cleanName) return null
|
||||
return (chapter.people || []).find(
|
||||
(person) => person.name === cleanName && clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function featuredPersonForPage(page, chapter) {
|
||||
const direct = personByInstanceId(chapter, page.actorInstanceId)
|
||||
if (direct) return direct
|
||||
|
||||
if (page.type === 'memory') {
|
||||
const memoryPerson = personByName(
|
||||
chapter,
|
||||
page.memoryCard && page.memoryCard.characterName,
|
||||
)
|
||||
if (memoryPerson) return memoryPerson
|
||||
}
|
||||
|
||||
if (page.type === 'emotion') {
|
||||
const emotionPerson = personByName(
|
||||
chapter,
|
||||
chapter.emotionMoment
|
||||
&& chapter.emotionMoment.character
|
||||
&& chapter.emotionMoment.character.name,
|
||||
)
|
||||
if (emotionPerson) return emotionPerson
|
||||
}
|
||||
|
||||
return (chapter.people || []).find(
|
||||
(person) => clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function createPlayableVisual(page, chapter) {
|
||||
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
|
||||
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
|
||||
}
|
||||
|
||||
if (registeredPageArtFor(page)) {
|
||||
return createPageArtVisual(
|
||||
page,
|
||||
chapter,
|
||||
RUNTIME_VISUAL_TIERS.PROVISIONAL,
|
||||
)
|
||||
}
|
||||
|
||||
if (page.type === 'event') {
|
||||
const portrait = clean(page.actorPortrait)
|
||||
if (portrait) {
|
||||
return {
|
||||
kind: 'actor-portrait',
|
||||
asset: portrait,
|
||||
subject: clean(page.actorName),
|
||||
actorInstanceId: clean(page.actorInstanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.actorName),
|
||||
evidenceLabel: clean(page.headline || '看看手边证据'),
|
||||
evidenceDetail: clean(
|
||||
page.secondaryCaption || page.caption || page.question,
|
||||
),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPerson = featuredPersonForPage(page, chapter)
|
||||
if (featuredPerson) {
|
||||
return {
|
||||
kind: 'chapter-character',
|
||||
asset: clean(featuredPerson.portrait),
|
||||
subject: clean(featuredPerson.name),
|
||||
actorInstanceId: clean(featuredPerson.instanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive last resort. The season currently never reaches this branch,
|
||||
// but if a future chapter lacks a portrait it still gets explicit evidence
|
||||
// instead of an unexplained empty room.
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.headline || chapter.title),
|
||||
evidenceLabel: clean(page.headline || chapter.title),
|
||||
evidenceDetail: clean(page.caption || chapter.narration),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
function attachPlayableVisuals(pageSequence, chapter) {
|
||||
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
|
||||
...page,
|
||||
playableVisual: createPlayableVisual(page, chapter),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORMAL_PAGE_ART_IDS,
|
||||
PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
RUNTIME_VISUAL_TIERS,
|
||||
attachPlayableVisuals,
|
||||
createPlayableVisual,
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Generated package-local production comic data. Do not hand-edit.
|
||||
module.exports = {
|
||||
"S01-C04": {
|
||||
"pages": [
|
||||
{
|
||||
"pageId": "S01-C04-P01",
|
||||
"type": "chapter-title",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C04-P01-title-v1.jpg",
|
||||
"caption": "一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "翻开这一回",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C04-MT000"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C04-P02",
|
||||
"type": "ensemble",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C04-P02-ensemble-v1.jpg",
|
||||
"caption": "“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "看一看,谁没有跟着笑?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C04-MS001",
|
||||
"S01-C04-MS002-ATTR",
|
||||
"S01-C04-MS002"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C04-P03",
|
||||
"type": "event",
|
||||
"eventId": "S01-H13",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C04-P03-H13-ladle-contest-v1.jpg",
|
||||
"caption": "赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "为证明能干,组织“谁吃得多”的比赛,这样妥当吗?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C04-MS003",
|
||||
"S01-C04-MS004"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C04-P04",
|
||||
"type": "event",
|
||||
"eventId": "S01-H14",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C04-P04-H14-belt-table-v1.jpg",
|
||||
"caption": "笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "已经很撑、仍隐瞒不适并准备马上弯腰抬重物,这样妥当吗?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"programDetailInset": {
|
||||
"label": "扶桌近景",
|
||||
"x": 13,
|
||||
"y": 50,
|
||||
"w": 42,
|
||||
"h": 47,
|
||||
"anchor": "top-left"
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C04-MS005"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C04-P05",
|
||||
"type": "event",
|
||||
"eventId": "S01-H15",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C04-P05-H15-water-reminder-v1.jpg",
|
||||
"caption": "小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "把“别又急又撑”理解成“主食一口都不能吃”,这样妥当吗?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C04-MS006",
|
||||
"S01-C04-MS007",
|
||||
"S01-C04-MS008",
|
||||
"S01-C04-MS009",
|
||||
"S01-C04-MS010",
|
||||
"S01-C04-MS011"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C04-P06",
|
||||
"type": "event",
|
||||
"eventId": "S01-H16",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C04-P06-H16-stool-bowl-v1.jpg",
|
||||
"caption": "林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "看见同伴扶桌后,先问是否需要坐一会儿,而不是继续起哄,这样更稳妥吗?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C04-MS012-ATTR",
|
||||
"S01-C04-MS012",
|
||||
"S01-C04-MS013",
|
||||
"S01-C04-MS014"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C04-P07",
|
||||
"type": "emotion",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "S01-EM04",
|
||||
"assetName": "S01-C04-P07-EM04-face-saving-pause-v1.jpg",
|
||||
"caption": "赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "不拆穿他的逞强,你想怎样接住这一刻?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C04-TE900"
|
||||
],
|
||||
"tableEcho": "饭能添,面子也要留;劝人停一停,先给他一张凳。",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C04-P08",
|
||||
"type": "memory-card-and-cliffhanger",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C04-P08-cliffhanger-v1.jpg",
|
||||
"caption": "饭能添,面子也要留;劝人停一停,先给他一张凳。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "收进我的桂香岁月",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C04-MS015"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* 发布固定资产清单 v1
|
||||
*
|
||||
* 约束:
|
||||
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
|
||||
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
|
||||
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
|
||||
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
|
||||
*/
|
||||
|
||||
const RELEASE_ASSET_MANIFEST_VERSION = 1
|
||||
|
||||
const releaseAssets = {
|
||||
'comic.s01.c01.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
|
||||
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
|
||||
},
|
||||
'comic.s01.c01.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
|
||||
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
|
||||
},
|
||||
'comic.s01.c01.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
|
||||
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
|
||||
},
|
||||
'comic.s01.c01.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
|
||||
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
|
||||
},
|
||||
'comic.s01.c01.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
|
||||
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
|
||||
},
|
||||
'comic.s01.c01.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
|
||||
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
|
||||
},
|
||||
'comic.s01.c01.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
|
||||
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
|
||||
},
|
||||
'comic.s01.c01.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
|
||||
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
|
||||
},
|
||||
'comic.s01.c02.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
|
||||
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
|
||||
},
|
||||
'comic.s01.c02.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
|
||||
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
|
||||
},
|
||||
'comic.s01.c02.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
|
||||
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
|
||||
},
|
||||
'comic.s01.c02.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
|
||||
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
|
||||
},
|
||||
'comic.s01.c02.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
|
||||
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
|
||||
},
|
||||
'comic.s01.c02.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
|
||||
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
|
||||
},
|
||||
'comic.s01.c02.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
|
||||
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
|
||||
},
|
||||
'comic.s01.c02.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
|
||||
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
|
||||
},
|
||||
'comic.s01.c03.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
|
||||
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
|
||||
},
|
||||
'comic.s01.c03.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
|
||||
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
|
||||
},
|
||||
'comic.s01.c03.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
|
||||
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
|
||||
},
|
||||
'comic.s01.c03.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
|
||||
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
|
||||
},
|
||||
'comic.s01.c03.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
|
||||
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
|
||||
},
|
||||
'comic.s01.c03.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
|
||||
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
|
||||
},
|
||||
'comic.s01.c03.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
|
||||
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
|
||||
},
|
||||
'comic.s01.c03.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
|
||||
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
|
||||
},
|
||||
'comic.s01.c04.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
|
||||
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
|
||||
},
|
||||
'comic.s01.c04.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
|
||||
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
|
||||
},
|
||||
'comic.s01.c04.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
|
||||
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
|
||||
},
|
||||
'comic.s01.c04.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
|
||||
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
|
||||
},
|
||||
'comic.s01.c04.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
|
||||
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
|
||||
},
|
||||
'comic.s01.c04.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
|
||||
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
|
||||
},
|
||||
'comic.s01.c04.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
|
||||
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
|
||||
},
|
||||
'comic.s01.c04.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
|
||||
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
|
||||
},
|
||||
'comic.s01.c05.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
|
||||
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
|
||||
},
|
||||
'comic.s01.c05.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
|
||||
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
|
||||
},
|
||||
'comic.s01.c05.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
|
||||
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
|
||||
},
|
||||
'comic.s01.c05.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
|
||||
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
|
||||
},
|
||||
'comic.s01.c05.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
|
||||
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
|
||||
},
|
||||
'comic.s01.c05.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
|
||||
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
|
||||
},
|
||||
'comic.s01.c05.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
|
||||
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
|
||||
},
|
||||
'comic.s01.c05.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
|
||||
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
|
||||
},
|
||||
'comic.s01.c06.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
|
||||
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
|
||||
},
|
||||
'comic.s01.c06.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
|
||||
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
|
||||
},
|
||||
'comic.s01.c06.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
|
||||
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
|
||||
},
|
||||
'comic.s01.c06.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
|
||||
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
|
||||
},
|
||||
'comic.s01.c06.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
|
||||
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
|
||||
},
|
||||
'comic.s01.c06.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
|
||||
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
|
||||
},
|
||||
'comic.s01.c06.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
|
||||
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
|
||||
},
|
||||
'comic.s01.c06.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
|
||||
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
|
||||
},
|
||||
'comic.s01.c07.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
|
||||
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
|
||||
},
|
||||
'comic.s01.c07.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
|
||||
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
|
||||
},
|
||||
'comic.s01.c07.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
|
||||
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
|
||||
},
|
||||
'comic.s01.c07.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
|
||||
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
|
||||
},
|
||||
'comic.s01.c07.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
|
||||
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
|
||||
},
|
||||
'comic.s01.c07.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
|
||||
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
|
||||
},
|
||||
'comic.s01.c07.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
|
||||
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
|
||||
},
|
||||
'comic.s01.c07.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
|
||||
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
|
||||
},
|
||||
'comic.s01.c08.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
|
||||
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
|
||||
},
|
||||
'comic.s01.c08.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
|
||||
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
|
||||
},
|
||||
'comic.s01.c08.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
|
||||
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
|
||||
},
|
||||
'comic.s01.c08.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
|
||||
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
|
||||
},
|
||||
'comic.s01.c08.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
|
||||
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
|
||||
},
|
||||
'comic.s01.c08.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
|
||||
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
|
||||
},
|
||||
'comic.s01.c08.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
|
||||
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
|
||||
},
|
||||
'comic.s01.c08.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
|
||||
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
|
||||
},
|
||||
'comic.s01.c09.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
|
||||
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
|
||||
},
|
||||
'comic.s01.c09.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
|
||||
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
|
||||
},
|
||||
'comic.s01.c09.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
|
||||
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
|
||||
},
|
||||
'comic.s01.c09.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
|
||||
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
|
||||
},
|
||||
'comic.s01.c09.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
|
||||
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
|
||||
},
|
||||
'comic.s01.c09.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
|
||||
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
|
||||
},
|
||||
'comic.s01.c09.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
|
||||
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
|
||||
},
|
||||
'comic.s01.c09.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
|
||||
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
|
||||
},
|
||||
'comic.s01.c10.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
|
||||
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
|
||||
},
|
||||
'comic.s01.c10.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
|
||||
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
|
||||
},
|
||||
'comic.s01.c10.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
|
||||
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
|
||||
},
|
||||
'comic.s01.c10.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
|
||||
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
|
||||
},
|
||||
'comic.s01.c10.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
|
||||
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
|
||||
},
|
||||
'comic.s01.c10.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
|
||||
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
|
||||
},
|
||||
'comic.s01.c10.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
|
||||
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
|
||||
},
|
||||
'comic.s01.c10.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
|
||||
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
|
||||
},
|
||||
'comic.s01.c11.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
|
||||
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
|
||||
},
|
||||
'comic.s01.c11.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
|
||||
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
|
||||
},
|
||||
'comic.s01.c11.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
|
||||
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
|
||||
},
|
||||
'comic.s01.c11.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
|
||||
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
|
||||
},
|
||||
'comic.s01.c11.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
|
||||
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
|
||||
},
|
||||
'comic.s01.c11.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
|
||||
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
|
||||
},
|
||||
'comic.s01.c11.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
|
||||
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
|
||||
},
|
||||
'comic.s01.c11.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
|
||||
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
|
||||
},
|
||||
'comic.s01.c12.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
|
||||
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
|
||||
},
|
||||
'comic.s01.c12.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
|
||||
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
|
||||
},
|
||||
'comic.s01.c12.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
|
||||
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
|
||||
},
|
||||
'comic.s01.c12.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
|
||||
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
|
||||
},
|
||||
'comic.s01.c12.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
|
||||
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
|
||||
},
|
||||
'comic.s01.c12.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
|
||||
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
|
||||
},
|
||||
'comic.s01.c12.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
|
||||
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
|
||||
},
|
||||
'comic.s01.c12.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
|
||||
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
|
||||
},
|
||||
'comic.s01.c13.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
|
||||
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
|
||||
},
|
||||
'comic.s01.c13.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
|
||||
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
|
||||
},
|
||||
'comic.s01.c13.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
|
||||
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
|
||||
},
|
||||
'comic.s01.c13.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
|
||||
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
|
||||
},
|
||||
'comic.s01.c13.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
|
||||
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
|
||||
},
|
||||
'comic.s01.c13.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
|
||||
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
|
||||
},
|
||||
'comic.s01.c13.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
|
||||
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
|
||||
},
|
||||
'comic.s01.c13.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
|
||||
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
|
||||
},
|
||||
'comic.s01.c14.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
|
||||
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
|
||||
},
|
||||
'comic.s01.c14.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
|
||||
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
|
||||
},
|
||||
'comic.s01.c14.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
|
||||
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
|
||||
},
|
||||
'comic.s01.c14.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
|
||||
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
|
||||
},
|
||||
'comic.s01.c14.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
|
||||
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
|
||||
},
|
||||
'comic.s01.c14.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
|
||||
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
|
||||
},
|
||||
'comic.s01.c14.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
|
||||
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
|
||||
},
|
||||
'comic.s01.c14.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
|
||||
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
|
||||
},
|
||||
'comic.s01.c15.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
|
||||
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
|
||||
},
|
||||
'comic.s01.c15.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
|
||||
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
|
||||
},
|
||||
'comic.s01.c15.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
|
||||
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
|
||||
},
|
||||
'comic.s01.c15.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
|
||||
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
|
||||
},
|
||||
'comic.s01.c15.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
|
||||
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
|
||||
},
|
||||
'comic.s01.c15.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
|
||||
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
|
||||
},
|
||||
'comic.s01.c15.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
|
||||
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
|
||||
},
|
||||
'comic.s01.c15.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
|
||||
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
|
||||
},
|
||||
'character.female-cook': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/female-cook.jpg',
|
||||
remotePath: 'characters/female-cook.c56420b2c632.jpg',
|
||||
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
|
||||
},
|
||||
'character.lele': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lele.jpg',
|
||||
remotePath: 'characters/lele.807f5232b1e8.jpg',
|
||||
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
|
||||
},
|
||||
'character.lin-xiulan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lin-xiulan.jpg',
|
||||
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
|
||||
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
|
||||
},
|
||||
'character.qin-xiaoman': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-xiaoman.jpg',
|
||||
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
|
||||
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
|
||||
},
|
||||
'character.qin-zhicheng': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-zhicheng.jpg',
|
||||
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
|
||||
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
|
||||
},
|
||||
'character.tang-mingyuan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-mingyuan.jpg',
|
||||
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
|
||||
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
|
||||
},
|
||||
'character.tang-shouan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-shouan.jpg',
|
||||
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
|
||||
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
|
||||
},
|
||||
'character.xiaozhen': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/xiaozhen.jpg',
|
||||
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
|
||||
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
|
||||
},
|
||||
'character.zhao-jianguo': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/zhao-jianguo.jpg',
|
||||
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
|
||||
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
|
||||
},
|
||||
'scene.gui-xiang.1978': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
|
||||
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
|
||||
},
|
||||
'scene.gui-xiang.1995': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
|
||||
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
|
||||
},
|
||||
'scene.gui-xiang.1998': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
|
||||
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
|
||||
},
|
||||
'scene.gui-xiang.2001': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
|
||||
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
|
||||
},
|
||||
'scene.gui-xiang.2003': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
|
||||
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
|
||||
},
|
||||
'scene.gui-xiang.2008': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
|
||||
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
|
||||
},
|
||||
'scene.gui-xiang.2026': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
|
||||
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
|
||||
},
|
||||
'audio.cast-audition.v1': {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'audition',
|
||||
localSeed: '',
|
||||
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
|
||||
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
|
||||
// The reader no longer maps them as page narration; C01 uses full-page tracks
|
||||
// through the two package-local audio player pages instead.
|
||||
for (const cueId of [
|
||||
'S01-C01-MS007','S01-C01-MS010',
|
||||
]) {
|
||||
const file = `${cueId}.mp3`
|
||||
const sha256 = {
|
||||
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
|
||||
}[cueId]
|
||||
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
|
||||
}
|
||||
|
||||
const audioC02Sha256 = {
|
||||
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
|
||||
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
|
||||
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
|
||||
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
|
||||
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
|
||||
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
|
||||
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
|
||||
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
|
||||
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
|
||||
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
|
||||
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
|
||||
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
|
||||
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
|
||||
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
|
||||
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
|
||||
}
|
||||
|
||||
const audioC03Sha256 = {
|
||||
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
|
||||
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
|
||||
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
|
||||
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
|
||||
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
|
||||
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
|
||||
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
|
||||
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
|
||||
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
|
||||
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
|
||||
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
|
||||
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
|
||||
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
|
||||
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
|
||||
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
|
||||
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
|
||||
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
|
||||
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
|
||||
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
|
||||
}
|
||||
|
||||
// C02/C03 cue files are retained only as rollback/source material. A single
|
||||
// cue is not a complete page track, so these assets must not pass the runtime
|
||||
// audio gate or appear as “试听本页”.
|
||||
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
|
||||
for (const [cueId, sha256] of Object.entries(audioHashes)) {
|
||||
const file = `${cueId}.mp3`
|
||||
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'reserved',
|
||||
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
|
||||
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RELEASE_ASSET_MANIFEST_VERSION,
|
||||
releaseAssets,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* C02-C15 remote full-page audio manifest v1.
|
||||
*
|
||||
* Import contract for a future reviewed batch:
|
||||
*
|
||||
* 'S01-C09-P05': Object.freeze({
|
||||
* pageId: 'S01-C09-P05',
|
||||
* chapterNumber: 9,
|
||||
* pageNumber: 5,
|
||||
* assetId: 'audio.page.s01.c09.p05',
|
||||
* durationSeconds: 42.1,
|
||||
* reviewStatus: 'approved',
|
||||
* })
|
||||
*
|
||||
* The matching releaseAssetManifest entry must independently be an approved,
|
||||
* remote-only, immutable hash-named audio asset. Automated QA, pending human
|
||||
* listening, and missing CDN records must never be relabelled as approved.
|
||||
*/
|
||||
|
||||
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
|
||||
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
// Deliberately empty until full-page tracks pass the required human reviews
|
||||
// and their immutable CDN objects are available. This is a release gate, not
|
||||
// a placeholder that the UI may treat as playable.
|
||||
const remotePageAudioPages = Object.freeze({})
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function remotePageAudioAssetId(pageId) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return ''
|
||||
return `audio.page.s01.c${match[1]}.p${match[2]}`
|
||||
}
|
||||
|
||||
function getApprovedRemotePageAudio(
|
||||
pageId,
|
||||
releaseAssets = {},
|
||||
pageManifest = remotePageAudioPages,
|
||||
) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return null
|
||||
|
||||
const entry = pageManifest && pageManifest[stablePageId]
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const chapterNumber = Number(match[1])
|
||||
const pageNumber = Number(match[2])
|
||||
const assetId = remotePageAudioAssetId(stablePageId)
|
||||
const durationSeconds = Number(entry.durationSeconds)
|
||||
if (
|
||||
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| entry.pageId !== stablePageId
|
||||
|| Number(entry.chapterNumber) !== chapterNumber
|
||||
|| Number(entry.pageNumber) !== pageNumber
|
||||
|| entry.assetId !== assetId
|
||||
|| !Number.isFinite(durationSeconds)
|
||||
|| durationSeconds <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const asset = releaseAssets && releaseAssets[assetId]
|
||||
const sha256 = asset && typeof asset.sha256 === 'string'
|
||||
? asset.sha256
|
||||
: ''
|
||||
const remotePath = asset && asset.remotePath
|
||||
const expectedRemotePath = (
|
||||
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
|
||||
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
|
||||
)
|
||||
if (
|
||||
!asset
|
||||
|| asset.kind !== 'audio'
|
||||
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| asset.localSeed !== ''
|
||||
|| !SHA256_PATTERN.test(sha256)
|
||||
|| typeof remotePath !== 'string'
|
||||
|| remotePath !== expectedRemotePath
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...entry, assetId }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REMOTE_PAGE_AUDIO_ID_PATTERN,
|
||||
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
|
||||
getApprovedRemotePageAudio,
|
||||
remotePageAudioAssetId,
|
||||
remotePageAudioPages,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "唐侦探",
|
||||
"pageOrientation": "landscape",
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
<view
|
||||
wx:if="{{chapter}}"
|
||||
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
|
||||
style="{{pageStyle}}"
|
||||
>
|
||||
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
|
||||
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
|
||||
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录">‹ 目录</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
|
||||
class="comic-top-previous-button"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一张画"
|
||||
>‹</button>
|
||||
<view class="comic-bookmark">
|
||||
<text class="comic-bookmark-label">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
|
||||
</text>
|
||||
<text class="comic-page-count">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
</view>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
|
||||
bindtap="openTextReading"
|
||||
aria-label="本页可以直接看文字"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text class="comic-page-audio-label">直接看文字</text>
|
||||
<text class="comic-page-audio-meta">按页慢慢看</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
|
||||
disabled="{{audioLoading}}"
|
||||
bindtap="toggleComicPageAudio"
|
||||
data-audio-cue-count="{{currentPage.audioCueCount}}"
|
||||
aria-label="本页语音播放控制"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">
|
||||
{{audioPlaying ? 'Ⅱ' : '▶'}}
|
||||
</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
|
||||
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
|
||||
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
|
||||
<text wx:else class="comic-page-audio-label">听一听</text>
|
||||
<text class="comic-page-audio-meta">
|
||||
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
|
||||
</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
|
||||
class="comic-font-button"
|
||||
bindtap="toggleFont"
|
||||
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
|
||||
>
|
||||
{{fontScale === 'large' ? '大字' : '标准字'}}
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
|
||||
class="c1-front-action-button"
|
||||
bindtap="openCurrentFrontAction"
|
||||
aria-label="{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
|
||||
: '点一下,翻到背面'}}"
|
||||
>
|
||||
{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '再看背面' : '点点人物')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '看看章末' : '听听心里话')
|
||||
: '翻到背面'}}
|
||||
</button>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view class="comic-reader">
|
||||
<block wx:if="{{storyFirstComicMode}}">
|
||||
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
|
||||
<block wx:if="{{chapterOneBackOpen}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">
|
||||
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边回声</text>
|
||||
<text>{{currentPage.memoryCard.tableEcho}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">带回生活里</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
<view class="c1-memory-secondary-actions">
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>看看生活观察</button>
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>发给家人聊聊</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{chapterOneReviewEvent.evidence}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边一句话</text>
|
||||
<text>“{{currentPage.shortDialogue}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text
|
||||
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
|
||||
class="c1-scroll-hint"
|
||||
>上划接着看 ↑</text>
|
||||
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
|
||||
</view>
|
||||
<view wx:else class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button
|
||||
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
|
||||
class="c1-back-button primary"
|
||||
bindtap="startChapterOneDecisionFromBack"
|
||||
>聊聊这事</button>
|
||||
<button
|
||||
wx:else
|
||||
class="c1-back-button primary"
|
||||
bindtap="nextChapterOnePageFromBack"
|
||||
>翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边回声</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">没有标准答案</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{emotionResult.feedback}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">这一桌记住了</text>
|
||||
<text>{{emotionResult.echo}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
|
||||
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
|
||||
<view class="c1-interaction-paper c1-emotion-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边一句话</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
|
||||
<text class="c1-tray-question">{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="c1-natural-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>{{item.text}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view class="c1-front-stage">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-front-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="c1-front-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="c1-front-grain"></view>
|
||||
|
||||
<button
|
||||
wx:if="{{!frontControlsRevealed}}"
|
||||
class="c1-front-reveal-gate"
|
||||
bindtap="revealCurrentFrontControls"
|
||||
aria-label="轻点画面,显示阅读按钮"
|
||||
></button>
|
||||
|
||||
<block wx:else>
|
||||
<button
|
||||
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
|
||||
class="c1-person-hotspot"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
|
||||
><view class="c1-hotspot-ring"></view></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="翻到这一幕的背面"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneEmotion"
|
||||
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:else
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="轻触画面,翻到这一页背面"
|
||||
></button>
|
||||
</block>
|
||||
|
||||
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
|
||||
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion'}}">
|
||||
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="special-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="special-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="emotion-panel">
|
||||
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="emotion-context">
|
||||
<text class="emotion-context-kicker">听听这一桌心里话</text>
|
||||
<text class="emotion-context-title">{{currentPage.headline}}</text>
|
||||
<text class="emotion-context-caption">{{currentPage.caption}}</text>
|
||||
</view>
|
||||
<text class="emotion-prompt">{{currentPage.prompt}}</text>
|
||||
<text
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-note"
|
||||
>没有标准答案,选您更愿意说的话。</text>
|
||||
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
|
||||
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
|
||||
<view class="table-echo">
|
||||
<text class="echo-label">桌边回声</text>
|
||||
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-choice-dock"
|
||||
>
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="emotion-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>
|
||||
<text class="choice-number">{{index + 1}}</text>
|
||||
<text>{{item.text}}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
|
||||
<button
|
||||
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{emotionResult || chapterFinished}}"
|
||||
class="special-page-button primary"
|
||||
bindtap="finishComicEmotion"
|
||||
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
|
||||
>
|
||||
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.isSeasonClose}}">
|
||||
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="season-close-body">
|
||||
<view class="season-close-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="season-close-main-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="season-close-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view
|
||||
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
|
||||
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
|
||||
aria-label="{{currentPage.detailInset.label}}近景"
|
||||
>
|
||||
<image
|
||||
class="season-close-detail-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{currentPage.detailInset.imageMode}}"
|
||||
style="{{currentPage.detailInset.imageStyle}}"
|
||||
></image>
|
||||
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="season-close-copy"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="season-close-copy-sheet">
|
||||
<text class="season-close-kicker">第一季 · 季终</text>
|
||||
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
|
||||
<view class="season-close-memory-card">
|
||||
<text class="season-close-card-label">桂香记忆卡</text>
|
||||
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
|
||||
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="season-close-life-line">
|
||||
<text class="season-close-line-label">今天可以这样做</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<view class="season-close-family-line">
|
||||
<text class="season-close-line-label">带回家聊一聊</text>
|
||||
<text>“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="memory-actions season-close-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="第一季读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'memory'}}">
|
||||
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
|
||||
<view class="memory-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="memory-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="memory-panel">
|
||||
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
|
||||
<view class="memory-paper-content">
|
||||
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
|
||||
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
|
||||
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
|
||||
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
|
||||
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
|
||||
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
|
||||
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
|
||||
</button>
|
||||
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="memory-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{chapterNumber >= chapterCount}}"
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="全书读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
<button
|
||||
wx:else
|
||||
class="memory-action-cell primary"
|
||||
bindtap="nextChapter"
|
||||
aria-label="翻到下一回"
|
||||
>
|
||||
<text class="memory-action-visible">翻到下一回</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view
|
||||
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
|
||||
bindtouchstart="onComicTouchStart"
|
||||
bindtouchend="onComicTouchEnd"
|
||||
>
|
||||
<view class="comic-art-column">
|
||||
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{comicPageImageMode}}"
|
||||
style="{{comicPageImageStyle}}"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="comic-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
<view
|
||||
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
|
||||
class="comic-evidence-composite"
|
||||
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
|
||||
>
|
||||
<text class="comic-evidence-kicker">手边证据</text>
|
||||
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
|
||||
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
|
||||
</view>
|
||||
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
|
||||
class="comic-actor-hotspot is-quiet"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
|
||||
></button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
|
||||
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
|
||||
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
|
||||
<view class="comic-cover-caption-heading">
|
||||
<text class="comic-caption-kicker">{{chapter.year}}</text>
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
</view>
|
||||
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
<view class="comic-ensemble-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
|
||||
<view class="comic-event-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
<view class="comic-event-cue">
|
||||
<text
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight}}"
|
||||
class="comic-outside-actor-button"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
|
||||
<text class="seen-chip">看见了</text>
|
||||
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
wx:if="{{currentInteractionEnabled && compactHeight}}"
|
||||
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
|
||||
>
|
||||
<text
|
||||
wx:if="{{currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
class="comic-outside-actor-button comic-outside-actor-dock"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="comic-page-nav">
|
||||
<button
|
||||
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
<button
|
||||
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="nextComicPage"
|
||||
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
|
||||
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">
|
||||
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
|
||||
</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
|
||||
<view
|
||||
class="text-reading-dialog paper-panel"
|
||||
aria-role="dialog"
|
||||
aria-label="本页大字文字"
|
||||
>
|
||||
<button
|
||||
class="dialog-close"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="关闭文字阅读"
|
||||
>×</button>
|
||||
<text class="text-reading-kicker">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
|
||||
</text>
|
||||
<scroll-view
|
||||
class="text-reading-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="text-reading-sheet">
|
||||
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
|
||||
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">这一页想和您聊</text>
|
||||
<text>{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">看看画里发生了什么</text>
|
||||
<text>{{currentPage.question}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<button
|
||||
class="text-reading-return"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="回到连环画"
|
||||
>回到画里</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
|
||||
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">画里的线索</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
|
||||
<text class="c1-tray-question">{{activeEvent.question}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{activeEvent.choiceOptions}}"
|
||||
wx:key="value"
|
||||
class="c1-natural-choice"
|
||||
data-value="{{item.value}}"
|
||||
bindtap="answer"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">再看看画里</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel c1-retry-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
|
||||
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
|
||||
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
|
||||
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-paper c1-event-result-back">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">这下有眉目了</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
|
||||
<text class="c1-back-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<block wx:else>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
<text class="c1-back-reason">{{activeEvent.reason}}</text>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text class="c1-scroll-hint">上划接着看 ↑</text>
|
||||
<view class="c1-back-footer">
|
||||
<button
|
||||
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="c1-back-button secondary"
|
||||
aria-label="回头再看一遍画面"
|
||||
bindtap="returnFromChapterOneDecision"
|
||||
>回去看画</button>
|
||||
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
|
||||
<view
|
||||
class="decision-dialog paper-panel comic-decision"
|
||||
aria-role="dialog"
|
||||
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
|
||||
>
|
||||
<button
|
||||
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="dialog-close"
|
||||
bindtap="closeDecision"
|
||||
aria-label="回到画里"
|
||||
>×</button>
|
||||
|
||||
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="character-story-inner">
|
||||
<view class="comic-focus-visual">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-focus-scene"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
style="{{comicFocusImageStyle}}"
|
||||
></image>
|
||||
<view class="comic-focus-shade"></view>
|
||||
<view class="comic-focus-name">
|
||||
<text>{{activePerson.eraLabel}} · 画中人物</text>
|
||||
<text class="focus-name">{{activeEvent.actorName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="story-label">这一幕发生了什么</text>
|
||||
<text class="action-description">{{activeEvent.actionDescription}}</text>
|
||||
|
||||
<view class="evidence-box">
|
||||
<text class="story-label">手边证据</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
|
||||
<view class="speech-box">
|
||||
<text class="story-label">桌边说话</text>
|
||||
<text>{{activeEvent.speech}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
|
||||
<scroll-view
|
||||
class="judgement-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{decisionResultNeedsScroll}}"
|
||||
>
|
||||
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
|
||||
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
|
||||
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
|
||||
|
||||
<view wx:if="{{resultMode === ''}}" class="judgement-options">
|
||||
<button class="judgement-button observe" data-value="caution" bindtap="answer">
|
||||
<text class="judgement-icon">A</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">先看看现场细节</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="judgement-button continue" data-value="safer" bindtap="answer">
|
||||
<text class="judgement-icon">B</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">继续按画里的做法</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
|
||||
<text class="result-title">没关系,再看看画里的细节。</text>
|
||||
<text>{{activeEvent.retryHint}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
|
||||
<text class="result-title">这处线索对上了。</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
|
||||
<text class="advice-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<view wx:else class="action-advice">
|
||||
<text class="advice-label">现在可以怎么做</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
|
||||
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
|
||||
</view>
|
||||
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="openResultDetail">
|
||||
再看不同情况怎么办
|
||||
</button>
|
||||
</view>
|
||||
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
|
||||
回到画里
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
|
||||
contractId: 'story-first-three-frame-v1',
|
||||
columns: Object.freeze({
|
||||
art: 3,
|
||||
content: 2,
|
||||
}),
|
||||
frames: Object.freeze({
|
||||
front: Object.freeze({
|
||||
imageMode: 'aspectFit',
|
||||
initialControls: 'none',
|
||||
revealGesture: 'single-tap',
|
||||
revealedControls: 'topbar-only',
|
||||
}),
|
||||
back: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
}),
|
||||
interaction: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
choiceCount: 2,
|
||||
choiceDirection: 'vertical',
|
||||
}),
|
||||
}),
|
||||
minimumControlPx: 48,
|
||||
compactHeightMaxPx: 620,
|
||||
fontModes: Object.freeze({
|
||||
standard: 'large',
|
||||
xlarge: 'xlarge',
|
||||
}),
|
||||
audioSlot: Object.freeze({
|
||||
placement: 'unified-topbar',
|
||||
minimumHeightPx: 48,
|
||||
textModeLabel: '直接看文字',
|
||||
readyLabel: '听一听',
|
||||
playingLabel: '暂停',
|
||||
replayLabel: '重听',
|
||||
usesPageAudioCueIds: true,
|
||||
}),
|
||||
artOverlayPolicy: Object.freeze({
|
||||
event: 'transparent-actor-hotspot-only',
|
||||
emotion: 'transparent-actor-hotspot-only',
|
||||
}),
|
||||
})
|
||||
|
||||
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
|
||||
cover: 'chapter-ensemble',
|
||||
ensemble: 'chapter-ensemble',
|
||||
event: 'actor-event',
|
||||
emotion: 'emotion-interaction',
|
||||
memory: 'memory-cliffhanger',
|
||||
})
|
||||
|
||||
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
|
||||
'S01-C01': 'front-back-comic-v1',
|
||||
'S01-C02': 'front-back-comic-v1',
|
||||
'S01-C03': 'front-back-comic-v1',
|
||||
'S01-C04': 'front-back-comic-v1',
|
||||
'S01-C05': 'front-back-comic-v1',
|
||||
'S01-C06': 'front-back-comic-v1',
|
||||
'S01-C07': 'front-back-comic-v1',
|
||||
'S01-C08': 'front-back-comic-v1',
|
||||
'S01-C09': 'front-back-comic-v1',
|
||||
'S01-C10': 'front-back-comic-v1',
|
||||
'S01-C11': 'front-back-comic-v1',
|
||||
'S01-C12': 'front-back-comic-v1',
|
||||
'S01-C13': 'front-back-comic-v1',
|
||||
'S01-C14': 'front-back-comic-v1',
|
||||
'S01-C15': 'front-back-comic-v1',
|
||||
})
|
||||
|
||||
function getComicPresentationMode(chapterId) {
|
||||
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|
||||
|| 'legacy-60-40-v1'
|
||||
}
|
||||
|
||||
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
|
||||
'S01-C09-P03': Object.freeze({
|
||||
layoutVariant: 'standard-landscape-60-40',
|
||||
behavior: 'key-safe-actor-hotspot',
|
||||
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
|
||||
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
|
||||
}),
|
||||
})
|
||||
|
||||
// These pages keep the full reviewed frame and add one read-only crop from
|
||||
// the same asset as a second comic panel. This is an art-reading aid, not a
|
||||
// new interaction or a page-layout exception.
|
||||
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
|
||||
'S01-C04-P04',
|
||||
'S01-C09-P01',
|
||||
'S01-C09-P05',
|
||||
'S01-C09-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C13-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function allowsFixedDetailInset(pageId) {
|
||||
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
|
||||
String(pageId || ''),
|
||||
) >= 0
|
||||
}
|
||||
|
||||
function getComicPageLayoutException(pageId) {
|
||||
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
|
||||
}
|
||||
|
||||
function resolveComicPageLayout(pageId, type) {
|
||||
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
|
||||
const exception = getComicPageLayoutException(pageId)
|
||||
return {
|
||||
templateKey,
|
||||
layoutVariant: exception
|
||||
? exception.layoutVariant
|
||||
: 'standard-landscape-60-40',
|
||||
layoutExceptionId: exception ? String(pageId) : '',
|
||||
layoutExceptionBehavior: exception ? exception.behavior : '',
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_READER_LAYOUT_CONTRACT,
|
||||
COMIC_PRESENTATION_BY_CHAPTER,
|
||||
COMIC_PAGE_TEMPLATE_BY_TYPE,
|
||||
COMIC_PAGE_LAYOUT_EXCEPTIONS,
|
||||
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
|
||||
allowsFixedDetailInset,
|
||||
getComicPresentationMode,
|
||||
getComicPageLayoutException,
|
||||
resolveComicPageLayout,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
const CACHE_INDEX_VERSION = 2
|
||||
const CACHE_FOLDER = 'tang-detective-assets-v2'
|
||||
const CACHE_INDEX_FILE = 'index.json'
|
||||
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_MAX_ENTRIES = 32
|
||||
|
||||
const SHA256_ROUND_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function utf8Bytes(value) {
|
||||
const encoded = encodeURIComponent(String(value || ''))
|
||||
const bytes = []
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] === '%') {
|
||||
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
|
||||
index += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
if (typeof value === 'string') return utf8Bytes(value)
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
throw new Error('unsupported SHA-256 input')
|
||||
}
|
||||
|
||||
function rotateRight(value, bits) {
|
||||
return (value >>> bits) | (value << (32 - bits))
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
const bytes = toBytes(value)
|
||||
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||
const message = new Uint8Array(paddedLength)
|
||||
message.set(bytes)
|
||||
message[bytes.length] = 0x80
|
||||
const bitLength = bytes.length * 8
|
||||
const highBits = Math.floor(bitLength / 0x100000000)
|
||||
const lowBits = bitLength >>> 0
|
||||
const lengthOffset = paddedLength - 8
|
||||
message[lengthOffset] = (highBits >>> 24) & 0xff
|
||||
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
|
||||
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
|
||||
message[lengthOffset + 3] = highBits & 0xff
|
||||
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
|
||||
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
|
||||
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
|
||||
message[lengthOffset + 7] = lowBits & 0xff
|
||||
|
||||
const hash = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]
|
||||
const words = new Uint32Array(64)
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const start = offset + index * 4
|
||||
words[index] = (
|
||||
(message[start] << 24)
|
||||
| (message[start + 1] << 16)
|
||||
| (message[start + 2] << 8)
|
||||
| message[start + 3]
|
||||
) >>> 0
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const word15 = words[index - 15]
|
||||
const word2 = words[index - 2]
|
||||
const sigma0 = (
|
||||
rotateRight(word15, 7)
|
||||
^ rotateRight(word15, 18)
|
||||
^ (word15 >>> 3)
|
||||
)
|
||||
const sigma1 = (
|
||||
rotateRight(word2, 17)
|
||||
^ rotateRight(word2, 19)
|
||||
^ (word2 >>> 10)
|
||||
)
|
||||
words[index] = (
|
||||
words[index - 16]
|
||||
+ sigma0
|
||||
+ words[index - 7]
|
||||
+ sigma1
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
let a = hash[0]
|
||||
let b = hash[1]
|
||||
let c = hash[2]
|
||||
let d = hash[3]
|
||||
let e = hash[4]
|
||||
let f = hash[5]
|
||||
let g = hash[6]
|
||||
let h = hash[7]
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
||||
const choice = (e & f) ^ (~e & g)
|
||||
const temp1 = (
|
||||
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
|
||||
) >>> 0
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (sum0 + majority) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0
|
||||
hash[1] = (hash[1] + b) >>> 0
|
||||
hash[2] = (hash[2] + c) >>> 0
|
||||
hash[3] = (hash[3] + d) >>> 0
|
||||
hash[4] = (hash[4] + e) >>> 0
|
||||
hash[5] = (hash[5] + f) >>> 0
|
||||
hash[6] = (hash[6] + g) >>> 0
|
||||
hash[7] = (hash[7] + h) >>> 0
|
||||
}
|
||||
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||
}
|
||||
|
||||
function constantTimeEqualHex(left, right) {
|
||||
const leftValue = String(left || '').toLowerCase()
|
||||
const rightValue = String(right || '').toLowerCase()
|
||||
const length = Math.max(leftValue.length, rightValue.length)
|
||||
let difference = leftValue.length ^ rightValue.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
|
||||
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
|
||||
difference |= leftCode ^ rightCode
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function joinPath(left, right) {
|
||||
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function normalizeIndex(value) {
|
||||
if (
|
||||
!value
|
||||
|| value.version !== CACHE_INDEX_VERSION
|
||||
|| !value.entries
|
||||
|| typeof value.entries !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: CACHE_INDEX_VERSION,
|
||||
entries: {},
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function callFs(fs, method, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs || typeof fs[method] !== 'function') {
|
||||
reject(new Error(`fs.${method} unavailable`))
|
||||
return
|
||||
}
|
||||
fs[method]({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createAssetPlatformFacade() {
|
||||
if (typeof wx === 'undefined') return null
|
||||
const env = Object.freeze({
|
||||
USER_DATA_PATH: String(
|
||||
wx.env && wx.env.USER_DATA_PATH || '',
|
||||
),
|
||||
})
|
||||
return Object.freeze({
|
||||
downloadFile(options) {
|
||||
if (typeof wx.downloadFile !== 'function') {
|
||||
if (options && typeof options.fail === 'function') {
|
||||
options.fail(new Error('wx.downloadFile unavailable'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return wx.downloadFile(options)
|
||||
},
|
||||
getFileSystemManager() {
|
||||
if (typeof wx.getFileSystemManager !== 'function') return null
|
||||
return wx.getFileSystemManager()
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
function download(assetPlatform, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
|
||||
reject(new Error('wx.downloadFile unavailable'))
|
||||
return
|
||||
}
|
||||
assetPlatform.downloadFile({
|
||||
url,
|
||||
success(result) {
|
||||
if (
|
||||
result
|
||||
&& result.statusCode >= 200
|
||||
&& result.statusCode < 300
|
||||
&& result.tempFilePath
|
||||
) {
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
reject(new Error(`download status ${result && result.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function safeCacheName(assetId, asset) {
|
||||
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
|
||||
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
|
||||
}
|
||||
|
||||
function createAssetManager(options = {}) {
|
||||
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
|
||||
const manifest = options.manifest || {}
|
||||
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
|
||||
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
|
||||
? Math.max(0, options.imageCacheBudgetBytes)
|
||||
: DEFAULT_IMAGE_BUDGET
|
||||
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
|
||||
? Math.max(0, options.audioCacheBudgetBytes)
|
||||
: DEFAULT_AUDIO_BUDGET
|
||||
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
|
||||
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
|
||||
: DEFAULT_AUDIO_MAX_ENTRIES
|
||||
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
|
||||
? Math.max(1, Math.floor(options.downloadConcurrency))
|
||||
: 2
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now
|
||||
const fs = assetPlatform
|
||||
&& typeof assetPlatform.getFileSystemManager === 'function'
|
||||
? assetPlatform.getFileSystemManager()
|
||||
: null
|
||||
const userDataPath = (
|
||||
assetPlatform
|
||||
&& assetPlatform.env
|
||||
&& assetPlatform.env.USER_DATA_PATH
|
||||
) || ''
|
||||
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
|
||||
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
|
||||
|
||||
let initialized = false
|
||||
let index = normalizeIndex(null)
|
||||
let activeDownloads = 0
|
||||
const inflight = new Map()
|
||||
const downloadWaiters = []
|
||||
|
||||
function fallback(assetId, reason) {
|
||||
return {
|
||||
assetId,
|
||||
available: false,
|
||||
uri: '',
|
||||
source: 'text-fallback',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCacheFolder() {
|
||||
if (!cacheRoot) return false
|
||||
try {
|
||||
await callFs(fs, 'mkdir', {
|
||||
dirPath: cacheRoot,
|
||||
recursive: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// EEXIST and older base-library mkdir failures are both safe to ignore.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function persistIndex() {
|
||||
if (!indexPath) return
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
await callFs(fs, 'writeFile', {
|
||||
filePath: indexPath,
|
||||
data: JSON.stringify(index),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
} catch (error) {
|
||||
// Cache metadata must never block the text-first game.
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
await callFs(fs, 'access', { path: filePath })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMatchesSha256(filePath, expectedSha256) {
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', { filePath })
|
||||
return constantTimeEqualHex(
|
||||
sha256Hex(result.data),
|
||||
expectedSha256,
|
||||
)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (!indexPath) return
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', {
|
||||
filePath: indexPath,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
index = normalizeIndex(JSON.parse(result.data))
|
||||
} catch (error) {
|
||||
index = normalizeIndex(null)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [assetId, entry] of Object.entries(index.entries)) {
|
||||
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await persistIndex()
|
||||
await enforceImageBudget()
|
||||
await enforceAudioBudget()
|
||||
}
|
||||
|
||||
function cacheBytes(kind) {
|
||||
return Object.values(index.entries).reduce(
|
||||
(total, entry) => (
|
||||
entry.kind === kind
|
||||
? total + Math.max(0, Number(entry.size) || 0)
|
||||
: total
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function cacheEntries(kind) {
|
||||
return Object.values(index.entries)
|
||||
.filter((entry) => entry.kind === kind)
|
||||
.length
|
||||
}
|
||||
|
||||
function imageCacheBytes() {
|
||||
return cacheBytes('image')
|
||||
}
|
||||
|
||||
function audioCacheBytes() {
|
||||
return cacheBytes('audio')
|
||||
}
|
||||
|
||||
async function unlinkQuietly(filePath) {
|
||||
try {
|
||||
await callFs(fs, 'unlink', { filePath })
|
||||
} catch (error) {
|
||||
// A missing cache file is already evicted.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceImageBudget(protectedAssetId = '') {
|
||||
let total = imageCacheBytes()
|
||||
if (total <= imageBudgetBytes) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'image' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= imageBudgetBytes) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function enforceAudioBudget(protectedAssetId = '') {
|
||||
let total = audioCacheBytes()
|
||||
let entries = cacheEntries('audio')
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'audio' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
entries -= 1
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function resolveCached(assetId, asset) {
|
||||
const entry = index.entries[assetId]
|
||||
if (!entry || entry.sha256 !== asset.sha256) return null
|
||||
if (!(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return null
|
||||
}
|
||||
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
|
||||
await unlinkQuietly(entry.filePath)
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
entry.lastAccessedAt = now()
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: entry.filePath,
|
||||
source: 'cache',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTempFileSize(tempFilePath, response) {
|
||||
if (Number.isFinite(response.fileSize)) return response.fileSize
|
||||
try {
|
||||
const statResult = await callFs(fs, 'stat', {
|
||||
path: tempFilePath,
|
||||
})
|
||||
const stat = statResult && statResult.stats
|
||||
return Math.max(0, Number(stat && stat.size) || 0)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(assetId, asset) {
|
||||
if (!cdnBaseUrl || !asset.remotePath) {
|
||||
return fallback(assetId, 'remote-disabled')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
activeDownloads += 1
|
||||
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1
|
||||
const next = downloadWaiters.shift()
|
||||
if (next) next()
|
||||
})
|
||||
}
|
||||
if (activeDownloads < downloadConcurrency) run()
|
||||
else downloadWaiters.push(run)
|
||||
})
|
||||
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
|
||||
await unlinkQuietly(response.tempFilePath)
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
const size = await getTempFileSize(response.tempFilePath, response)
|
||||
|
||||
const exceedsCachePolicy = (
|
||||
(asset.kind === 'image' && size > imageBudgetBytes)
|
||||
|| (
|
||||
asset.kind === 'audio'
|
||||
&& (size > audioBudgetBytes || audioMaxEntries < 1)
|
||||
)
|
||||
)
|
||||
if (!cacheRoot || exceedsCachePolicy) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
|
||||
await callFs(fs, 'saveFile', {
|
||||
tempFilePath: response.tempFilePath,
|
||||
filePath,
|
||||
})
|
||||
index.entries[assetId] = {
|
||||
assetId,
|
||||
filePath,
|
||||
kind: asset.kind,
|
||||
sha256: asset.sha256,
|
||||
size,
|
||||
lastAccessedAt: now(),
|
||||
}
|
||||
await enforceImageBudget(assetId)
|
||||
await enforceAudioBudget(assetId)
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: filePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(assetId, resolveOptions = {}) {
|
||||
await init()
|
||||
const asset = manifest[assetId]
|
||||
if (!asset) return fallback(assetId, 'unknown-asset')
|
||||
if (
|
||||
asset.kind === 'audio'
|
||||
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
|
||||
) {
|
||||
return fallback(assetId, 'audio-unapproved')
|
||||
}
|
||||
|
||||
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
|
||||
if (asset.localSeed && resolveOptions.preferRemote !== true) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
const cached = await resolveCached(assetId, asset)
|
||||
if (cached) return cached
|
||||
|
||||
if (resolveOptions.allowRemote === false) {
|
||||
if (asset.localSeed) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
return fallback(assetId, 'remote-disallowed')
|
||||
}
|
||||
|
||||
if (inflight.has(assetId)) return inflight.get(assetId)
|
||||
|
||||
const request = fetchRemote(assetId, asset)
|
||||
.catch(() => (
|
||||
asset.localSeed
|
||||
? {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
: fallback(assetId, 'remote-failed')
|
||||
))
|
||||
.finally(() => inflight.delete(assetId))
|
||||
inflight.set(assetId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function prefetch(assetIds) {
|
||||
const ids = Array.isArray(assetIds) ? assetIds : []
|
||||
return Promise.all(ids.map((assetId) => resolve(assetId)))
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await init()
|
||||
const entries = Object.values(index.entries)
|
||||
for (const entry of entries) await unlinkQuietly(entry.filePath)
|
||||
index = normalizeIndex(null)
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return {
|
||||
entries: Object.keys(index.entries).length,
|
||||
imageBytes: imageCacheBytes(),
|
||||
imageBudgetBytes,
|
||||
audioEntries: cacheEntries('audio'),
|
||||
audioBytes: audioCacheBytes(),
|
||||
audioBudgetBytes,
|
||||
audioMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
resolve,
|
||||
prefetch,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
getAsset(assetId) {
|
||||
return manifest[assetId] || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_INDEX_VERSION,
|
||||
DEFAULT_AUDIO_BUDGET,
|
||||
DEFAULT_AUDIO_MAX_ENTRIES,
|
||||
constantTimeEqualHex,
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
sha256Hex,
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
const REVIEWED_AUDIO_STATUS = 'approved'
|
||||
const REVIEWED_VISUAL_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_STATUS = 'experience-provisional'
|
||||
const PROVISIONAL_VISUAL_TIER = 'experience-provisional'
|
||||
const IMAGE_UNAVAILABLE_MESSAGE = '本页画面暂时没有打开,文字和互动仍可继续。'
|
||||
const AUDIO_FILE_PATTERN = /\.(?:aac|m4a|mp3|ogg|wav)$/i
|
||||
|
||||
function cleanPath(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isPlayableVisualDeclaration(visual) {
|
||||
if (!visual || typeof visual !== 'object') return false
|
||||
const reviewStatus = cleanPath(visual.reviewStatus).toLowerCase()
|
||||
if (reviewStatus === REVIEWED_VISUAL_STATUS) return true
|
||||
return Boolean(
|
||||
visual.kind === 'page-art'
|
||||
&& cleanPath(visual.runtimeTier) === PROVISIONAL_VISUAL_TIER
|
||||
&& visual.formalReleaseEligible === false
|
||||
&& reviewStatus === PROVISIONAL_VISUAL_STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
function isPackagedPath(value) {
|
||||
const path = cleanPath(value)
|
||||
return (
|
||||
path.startsWith('/assets/')
|
||||
|| /^\/package-[a-z0-9-]+\//i.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
function actorSheetFocusPercent(person) {
|
||||
const era = String(person && person.eraLabel || '')
|
||||
const characterId = String(person && person.characterId || '')
|
||||
if (characterId === 'qin-xiaoman') {
|
||||
if (/2008/.test(era)) return 16
|
||||
if (/2026/.test(era)) return 65
|
||||
}
|
||||
if (characterId === 'tang-mingyuan') {
|
||||
if (/1995/.test(era)) return 11
|
||||
if (/2001/.test(era)) return 37
|
||||
if (/2003/.test(era)) return 63
|
||||
if (/2026/.test(era)) return 88
|
||||
}
|
||||
if (/1978|1980/.test(era)) return 11
|
||||
if (/1993|1995|1998/.test(era)) return 31
|
||||
if (/2001|2003/.test(era)) return 50
|
||||
if (/2008/.test(era)) return 69
|
||||
if (/2026/.test(era)) return 88
|
||||
return 50
|
||||
}
|
||||
|
||||
function isSinglePortraitFallback(person) {
|
||||
return String(person && person.characterId || '') === 'female-cook'
|
||||
}
|
||||
|
||||
function getComicPageImageMode(person, usingActorFallback) {
|
||||
return usingActorFallback && isSinglePortraitFallback(person)
|
||||
? 'aspectFit'
|
||||
: 'scaleToFill'
|
||||
}
|
||||
|
||||
function getComicPageImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const focusX = actorSheetFocusPercent(person)
|
||||
const translateX = 50 - (3.5 * focusX)
|
||||
return [
|
||||
'transform-origin:0 0',
|
||||
`transform:translate(${translateX}%,-30%) scale(3.5)`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
function getComicFocusImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const sourceFocusX = actorSheetFocusPercent(person)
|
||||
const focusX = 25 + (sourceFocusX * 0.5)
|
||||
return [
|
||||
'transform:scale(7)',
|
||||
`transform-origin:${focusX}% 12%`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first image shown by a comic page.
|
||||
*
|
||||
* A release-manifest localSeed is authoritative and must never wait for the
|
||||
* network. A remote-only illustration starts on the reviewed scene fallback,
|
||||
* so a slow or failed download cannot leave a blank reader.
|
||||
*/
|
||||
function buildComicImageState(page = {}, releaseAsset = null, previous = {}) {
|
||||
const illustrationAsset = cleanPath(page.illustrationAsset)
|
||||
const fallbackAsset = cleanPath(page.fallbackAsset)
|
||||
const playableVisual = isPlayableVisualDeclaration(page.playableVisual)
|
||||
? page.playableVisual
|
||||
: null
|
||||
const sharedVisualKind = playableVisual
|
||||
&& playableVisual.kind !== 'page-art'
|
||||
? cleanPath(playableVisual.kind)
|
||||
: ''
|
||||
const sharedVisualAsset = sharedVisualKind
|
||||
? cleanPath(playableVisual.asset)
|
||||
: ''
|
||||
const actorFallbackAsset = cleanPath(
|
||||
(
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
? sharedVisualAsset
|
||||
: (page.actorFallbackAsset || page.actorPortrait),
|
||||
)
|
||||
const localSeed = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.localSeed)
|
||||
: ''
|
||||
const remotePath = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.remotePath)
|
||||
: ''
|
||||
const keepFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& fallbackAsset
|
||||
)
|
||||
const keepActorFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& previous.comicImageSource === 'actor-fallback'
|
||||
&& actorFallbackAsset
|
||||
)
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& (
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: sharedVisualAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'shared-portrait',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& sharedVisualKind === 'evidence-composite'
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'shared-evidence',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepActorFallback) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepFallback) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (localSeed) {
|
||||
return {
|
||||
src: localSeed,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'local-seed',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (remotePath) {
|
||||
const loadingFallback = actorFallbackAsset || fallbackAsset
|
||||
const usingActorFallback = Boolean(actorFallbackAsset)
|
||||
return {
|
||||
src: loadingFallback,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: Boolean(loadingFallback),
|
||||
usingActorFallback,
|
||||
source: usingActorFallback
|
||||
? 'actor-fallback-loading'
|
||||
: (fallbackAsset ? 'fallback-loading' : 'text-fallback-loading'),
|
||||
error: loadingFallback ? '' : IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseAsset && illustrationAsset) {
|
||||
return {
|
||||
src: illustrationAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'declared-local-path',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (actorFallbackAsset) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
src: '',
|
||||
fallback: '',
|
||||
actorFallback: '',
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'text-fallback',
|
||||
error: IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio is intentionally fail-closed. A non-empty src alone is not enough:
|
||||
* both the story data and immutable release manifest must mark the file as
|
||||
* approved, and the playable src must be the packaged, hash-checked seed.
|
||||
*/
|
||||
function getReviewedAudioSrc(media = {}, manifest = {}) {
|
||||
const status = cleanPath(media.status || media.audioStatus).toLowerCase()
|
||||
const assetId = cleanPath(media.assetId || media.audioAssetId)
|
||||
const declaredSrc = cleanPath(media.src || media.audioSrc)
|
||||
if (
|
||||
status !== REVIEWED_AUDIO_STATUS
|
||||
|| !assetId
|
||||
|| !declaredSrc
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const releaseAsset = manifest[assetId]
|
||||
if (
|
||||
!releaseAsset
|
||||
|| releaseAsset.kind !== 'audio'
|
||||
|| cleanPath(releaseAsset.reviewStatus).toLowerCase()
|
||||
!== REVIEWED_AUDIO_STATUS
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const localSeed = cleanPath(releaseAsset.localSeed)
|
||||
if (
|
||||
!localSeed
|
||||
|| localSeed !== declaredSrc
|
||||
|| !isPackagedPath(localSeed)
|
||||
|| !AUDIO_FILE_PATTERN.test(localSeed)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return localSeed
|
||||
}
|
||||
|
||||
function buildReviewedAudioState(media = {}, manifest = {}) {
|
||||
const src = getReviewedAudioSrc(media, manifest)
|
||||
return {
|
||||
available: Boolean(src),
|
||||
src,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDIO_FILE_PATTERN,
|
||||
IMAGE_UNAVAILABLE_MESSAGE,
|
||||
REVIEWED_AUDIO_STATUS,
|
||||
buildComicImageState,
|
||||
buildReviewedAudioState,
|
||||
getComicFocusImageStyle,
|
||||
getComicPageImageMode,
|
||||
getComicPageImageStyle,
|
||||
getReviewedAudioSrc,
|
||||
isPackagedPath,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
const COMIC_PAGE_TYPES = Object.freeze([
|
||||
'cover',
|
||||
'ensemble',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'emotion',
|
||||
'memory',
|
||||
])
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicPages(model = {}) {
|
||||
return Array.isArray(model.pageSequence) ? model.pageSequence : []
|
||||
}
|
||||
|
||||
function pageIndex(model, pageId) {
|
||||
const requested = cleanString(pageId)
|
||||
if (!requested) return -1
|
||||
return comicPages(model).findIndex((page) => page.pageId === requested)
|
||||
}
|
||||
|
||||
function eventPages(model) {
|
||||
return comicPages(model).filter((page) => page.type === 'event')
|
||||
}
|
||||
|
||||
function isEightPageComicModel(model = {}) {
|
||||
const pages = comicPages(model)
|
||||
if (
|
||||
model.mode !== 'comic'
|
||||
|| !cleanString(model.chapterId)
|
||||
|| pages.length !== COMIC_PAGE_TYPES.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return pages.every((page, index) => (
|
||||
page
|
||||
&& page.pageId === `${model.chapterId}-P${String(index + 1).padStart(2, '0')}`
|
||||
&& page.type === COMIC_PAGE_TYPES[index]
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a contiguous prefix of P03-P06 is trusted.
|
||||
*
|
||||
* This prevents stale, foreign, duplicated or out-of-order ids from opening a
|
||||
* later page. It also keeps the locked 60-event source order unchanged.
|
||||
*/
|
||||
function normalizeCompletedEventIds(model, completedIds = []) {
|
||||
const supplied = new Set(
|
||||
(Array.isArray(completedIds) ? completedIds : [])
|
||||
.map(cleanString)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const normalized = []
|
||||
for (const page of eventPages(model)) {
|
||||
if (!supplied.has(page.eventId)) break
|
||||
normalized.push(page.eventId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getUnlockedPageIndex(
|
||||
model,
|
||||
completedIds = [],
|
||||
chapterFinished = false,
|
||||
) {
|
||||
const pages = comicPages(model)
|
||||
if (!pages.length) return -1
|
||||
if (!isEightPageComicModel(model)) return 0
|
||||
if (chapterFinished) return pages.length - 1
|
||||
|
||||
const completed = normalizeCompletedEventIds(model, completedIds)
|
||||
const firstEventIndex = pages.findIndex((page) => page.type === 'event')
|
||||
const emotionIndex = pages.findIndex((page) => page.type === 'emotion')
|
||||
if (firstEventIndex < 0) return 0
|
||||
|
||||
return Math.min(
|
||||
emotionIndex >= 0 ? emotionIndex : pages.length - 1,
|
||||
firstEventIndex + completed.length,
|
||||
)
|
||||
}
|
||||
|
||||
function emptyReaderState() {
|
||||
return {
|
||||
valid: false,
|
||||
currentPage: null,
|
||||
currentPageId: '',
|
||||
currentPageIndex: -1,
|
||||
unlockedPageIndex: -1,
|
||||
unlockedPageIds: [],
|
||||
completedEventIds: [],
|
||||
allEventsComplete: false,
|
||||
chapterFinished: false,
|
||||
canGoPrevious: false,
|
||||
canGoNext: false,
|
||||
nextPageLocked: false,
|
||||
activeInteraction: null,
|
||||
activeHotspots: [],
|
||||
pageAccess: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildComicReaderState(model, progress = {}) {
|
||||
if (!isEightPageComicModel(model)) return emptyReaderState()
|
||||
|
||||
const pages = comicPages(model)
|
||||
const completedEventIds = normalizeCompletedEventIds(
|
||||
model,
|
||||
progress.completedEventIds || progress.completedIds,
|
||||
)
|
||||
const allEventsComplete = (
|
||||
completedEventIds.length === eventPages(model).length
|
||||
)
|
||||
const chapterFinished = Boolean(
|
||||
progress.chapterFinished && allEventsComplete,
|
||||
)
|
||||
const unlockedPageIndex = getUnlockedPageIndex(
|
||||
model,
|
||||
completedEventIds,
|
||||
chapterFinished,
|
||||
)
|
||||
const requestedPageId = (
|
||||
cleanString(progress.currentPageId)
|
||||
|| cleanString(progress.savedPageId)
|
||||
|| model.firstPageId
|
||||
|| pages[0].pageId
|
||||
)
|
||||
const requestedIndex = pageIndex(model, requestedPageId)
|
||||
const currentPageIndex = Math.min(
|
||||
unlockedPageIndex,
|
||||
Math.max(0, requestedIndex >= 0 ? requestedIndex : 0),
|
||||
)
|
||||
const currentPage = pages[currentPageIndex]
|
||||
const completed = new Set(completedEventIds)
|
||||
|
||||
let activeInteraction = null
|
||||
if (
|
||||
currentPage.type === 'event'
|
||||
&& !completed.has(currentPage.eventId)
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'event',
|
||||
id: currentPage.eventId,
|
||||
eventId: currentPage.eventId,
|
||||
pageId: currentPage.pageId,
|
||||
actorInstanceId: currentPage.actorInstanceId || '',
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
} else if (
|
||||
currentPage.type === 'emotion'
|
||||
&& allEventsComplete
|
||||
&& !chapterFinished
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'emotion',
|
||||
id: currentPage.emotionMomentId,
|
||||
emotionMomentId: currentPage.emotionMomentId,
|
||||
pageId: currentPage.pageId,
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
}
|
||||
|
||||
const activeHotspots = (
|
||||
activeInteraction
|
||||
&& activeInteraction.actorHotspot
|
||||
)
|
||||
? [{
|
||||
interactionId: activeInteraction.id,
|
||||
pageId: activeInteraction.pageId,
|
||||
actorHotspot: activeInteraction.actorHotspot,
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
currentPage,
|
||||
currentPageId: currentPage.pageId,
|
||||
currentPageIndex,
|
||||
unlockedPageIndex,
|
||||
unlockedPageIds: pages
|
||||
.slice(0, unlockedPageIndex + 1)
|
||||
.map((page) => page.pageId),
|
||||
completedEventIds,
|
||||
allEventsComplete,
|
||||
chapterFinished,
|
||||
canGoPrevious: currentPageIndex > 0,
|
||||
canGoNext: currentPageIndex < unlockedPageIndex,
|
||||
nextPageLocked: (
|
||||
currentPageIndex < pages.length - 1
|
||||
&& currentPageIndex >= unlockedPageIndex
|
||||
),
|
||||
activeInteraction,
|
||||
activeHotspots,
|
||||
pageAccess: pages.map((page, index) => ({
|
||||
pageId: page.pageId,
|
||||
pageIndex: index,
|
||||
type: page.type,
|
||||
unlocked: index <= unlockedPageIndex,
|
||||
current: index === currentPageIndex,
|
||||
interactionEnabled: Boolean(
|
||||
activeInteraction
|
||||
&& activeInteraction.pageId === page.pageId
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function progressFromState(state) {
|
||||
return {
|
||||
currentPageId: state.currentPageId,
|
||||
completedEventIds: [...state.completedEventIds],
|
||||
chapterFinished: state.chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one chapter from the shared v1 storage object.
|
||||
*
|
||||
* `comicReaderByChapter` is the canonical reader cursor. The older
|
||||
* completedHotspots/completedChapters/lastPageId fields remain mirrored for
|
||||
* catalog compatibility and safe migration from already-installed builds.
|
||||
*/
|
||||
function readComicReaderProgress(storageProgress = {}, model = {}, chapterNumber) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
const storedByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? storageProgress.comicReaderByChapter
|
||||
: {}
|
||||
const storedChapter = (
|
||||
chapterId
|
||||
&& storedByChapter[chapterId]
|
||||
&& typeof storedByChapter[chapterId] === 'object'
|
||||
)
|
||||
? storedByChapter[chapterId]
|
||||
: {}
|
||||
const legacyHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
&& Array.isArray(storageProgress.completedHotspots[chapterId])
|
||||
)
|
||||
? storageProgress.completedHotspots[chapterId]
|
||||
: []
|
||||
const legacyFinished = (
|
||||
Array.isArray(storageProgress.completedChapters)
|
||||
&& storageProgress.completedChapters.includes(chapterId)
|
||||
)
|
||||
const chapterFinished = (
|
||||
typeof storedChapter.chapterFinished === 'boolean'
|
||||
? storedChapter.chapterFinished
|
||||
: legacyFinished
|
||||
)
|
||||
const isLastChapter = (
|
||||
Number(storageProgress.lastChapter) === Number(chapterNumber)
|
||||
)
|
||||
|
||||
return {
|
||||
currentPageId: (
|
||||
cleanString(storedChapter.currentPageId)
|
||||
|| (isLastChapter ? cleanString(storageProgress.lastPageId) : '')
|
||||
|| (chapterFinished ? cleanString(model.lastPageId) : '')
|
||||
|| cleanString(model.firstPageId)
|
||||
),
|
||||
completedEventIds: Array.isArray(storedChapter.completedEventIds)
|
||||
? [...storedChapter.completedEventIds]
|
||||
: [...legacyHotspots],
|
||||
chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge only the current chapter back into shared storage.
|
||||
*
|
||||
* Other chapters and unrelated product fields are copied through untouched.
|
||||
* The supplied reader progress is normalized again here so stale `finished`
|
||||
* flags and out-of-order future event ids cannot be persisted.
|
||||
*/
|
||||
function mergeComicReaderProgress(
|
||||
storageProgress = {},
|
||||
model = {},
|
||||
chapterNumber,
|
||||
readerProgress = {},
|
||||
) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
if (!chapterId || !isEightPageComicModel(model)) {
|
||||
return { ...storageProgress }
|
||||
}
|
||||
const state = buildComicReaderState(model, readerProgress)
|
||||
const normalized = progressFromState(state)
|
||||
const completedHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
)
|
||||
? { ...storageProgress.completedHotspots }
|
||||
: {}
|
||||
const comicReaderByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? { ...storageProgress.comicReaderByChapter }
|
||||
: {}
|
||||
const completedChapters = Array.isArray(storageProgress.completedChapters)
|
||||
? storageProgress.completedChapters.filter(
|
||||
(storedChapterId) => storedChapterId !== chapterId,
|
||||
)
|
||||
: []
|
||||
|
||||
completedHotspots[chapterId] = [...normalized.completedEventIds]
|
||||
comicReaderByChapter[chapterId] = normalized
|
||||
if (normalized.chapterFinished) completedChapters.push(chapterId)
|
||||
|
||||
return {
|
||||
...storageProgress,
|
||||
completedHotspots,
|
||||
completedChapters,
|
||||
comicReaderByChapter,
|
||||
lastChapter: Number(chapterNumber) || model.chapterNumber || 1,
|
||||
lastPageId: normalized.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transition helper. Completing an interaction unlocks the following
|
||||
* page but never turns it automatically, so the reader still controls the
|
||||
* lianhuanhua rhythm.
|
||||
*/
|
||||
function applyComicReaderAction(model, progress = {}, action = {}) {
|
||||
const state = buildComicReaderState(model, progress)
|
||||
if (!state.valid) return progressFromState(state)
|
||||
const type = cleanString(action.type)
|
||||
let nextProgress = progressFromState(state)
|
||||
|
||||
if (type === 'previous-page' && state.canGoPrevious) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex - 1
|
||||
].pageId
|
||||
} else if (type === 'next-page' && state.canGoNext) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex + 1
|
||||
].pageId
|
||||
} else if (type === 'open-page') {
|
||||
const targetIndex = pageIndex(model, action.pageId)
|
||||
if (targetIndex >= 0 && targetIndex <= state.unlockedPageIndex) {
|
||||
nextProgress.currentPageId = comicPages(model)[targetIndex].pageId
|
||||
}
|
||||
} else if (
|
||||
type === 'complete-event'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'event'
|
||||
&& state.activeInteraction.eventId === cleanString(action.eventId)
|
||||
) {
|
||||
nextProgress.completedEventIds = [
|
||||
...state.completedEventIds,
|
||||
state.activeInteraction.eventId,
|
||||
]
|
||||
} else if (
|
||||
type === 'complete-emotion'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'emotion'
|
||||
&& state.activeInteraction.emotionMomentId
|
||||
=== cleanString(action.emotionMomentId)
|
||||
) {
|
||||
nextProgress.chapterFinished = true
|
||||
}
|
||||
|
||||
return progressFromState(buildComicReaderState(model, nextProgress))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared illustration path is not proof that a finished page exists.
|
||||
* Callers must explicitly confirm formalAvailable after package/manifest
|
||||
* validation. Otherwise the state is visibly a fallback, never "formal art".
|
||||
*/
|
||||
function buildComicArtAvailability(page = {}, options = {}) {
|
||||
const formalAsset = cleanString(
|
||||
options.formalAsset || page.illustrationAsset,
|
||||
)
|
||||
const fallbackAsset = cleanString(
|
||||
options.fallbackAsset || page.fallbackAsset,
|
||||
)
|
||||
if (options.formalAvailable === true && formalAsset) {
|
||||
return {
|
||||
mode: 'formal-art',
|
||||
src: formalAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: true,
|
||||
isFallback: false,
|
||||
}
|
||||
}
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
mode: 'scene-fallback',
|
||||
src: fallbackAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'text-only',
|
||||
src: '',
|
||||
formalAsset,
|
||||
fallbackAsset: '',
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_PAGE_TYPES,
|
||||
applyComicReaderAction,
|
||||
buildComicArtAvailability,
|
||||
buildComicReaderState,
|
||||
getUnlockedPageIndex,
|
||||
isEightPageComicModel,
|
||||
mergeComicReaderProgress,
|
||||
normalizeCompletedEventIds,
|
||||
pageIndex,
|
||||
readComicReaderProgress,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 发布环境可覆盖这些值。
|
||||
*
|
||||
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
|
||||
* 使用包内种子图或返回文字回退,不会发起网络请求。
|
||||
*/
|
||||
module.exports = {
|
||||
cdnBaseUrl: '',
|
||||
imageCacheBudgetBytes: 28 * 1024 * 1024,
|
||||
audioCacheBudgetBytes: 32 * 1024 * 1024,
|
||||
audioCacheMaxEntries: 32,
|
||||
prefetchAhead: 2,
|
||||
downloadConcurrency: 2,
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
const { releaseAssets } = require('./releaseAssetManifest')
|
||||
|
||||
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
|
||||
const RUNTIME_VISUAL_TIERS = Object.freeze({
|
||||
FORMAL: 'formal',
|
||||
PROVISIONAL: 'experience-provisional',
|
||||
FALLBACK: 'fallback',
|
||||
})
|
||||
|
||||
// This set deliberately mirrors `usable` in
|
||||
// docs/production/art-release-gate-s01.json. Registered page art outside this
|
||||
// set may be shown only as experience-provisional and never counts as formal.
|
||||
const FORMAL_PAGE_ART_IDS = new Set([
|
||||
'S01-C01-P01',
|
||||
'S01-C01-P02',
|
||||
'S01-C01-P03',
|
||||
'S01-C01-P04',
|
||||
'S01-C01-P05',
|
||||
'S01-C01-P07',
|
||||
'S01-C01-P08',
|
||||
'S01-C02-P01',
|
||||
'S01-C02-P02',
|
||||
'S01-C02-P03',
|
||||
'S01-C02-P04',
|
||||
'S01-C02-P05',
|
||||
'S01-C02-P06',
|
||||
'S01-C02-P07',
|
||||
'S01-C02-P08',
|
||||
'S01-C03-P01',
|
||||
'S01-C03-P02',
|
||||
'S01-C03-P03',
|
||||
'S01-C03-P04',
|
||||
'S01-C03-P05',
|
||||
'S01-C03-P07',
|
||||
'S01-C03-P08',
|
||||
'S01-C04-P01',
|
||||
'S01-C04-P02',
|
||||
'S01-C04-P03',
|
||||
'S01-C04-P05',
|
||||
'S01-C04-P06',
|
||||
'S01-C04-P07',
|
||||
'S01-C04-P08',
|
||||
'S01-C05-P01',
|
||||
'S01-C05-P02',
|
||||
'S01-C05-P03',
|
||||
'S01-C05-P04',
|
||||
'S01-C05-P05',
|
||||
'S01-C05-P06',
|
||||
'S01-C05-P07',
|
||||
'S01-C05-P08',
|
||||
'S01-C06-P01',
|
||||
'S01-C06-P02',
|
||||
'S01-C06-P03',
|
||||
'S01-C06-P04',
|
||||
'S01-C06-P05',
|
||||
'S01-C06-P06',
|
||||
'S01-C06-P07',
|
||||
'S01-C06-P08',
|
||||
'S01-C07-P01',
|
||||
'S01-C07-P02',
|
||||
'S01-C07-P03',
|
||||
'S01-C07-P04',
|
||||
'S01-C07-P05',
|
||||
'S01-C07-P06',
|
||||
'S01-C07-P07',
|
||||
'S01-C07-P08',
|
||||
'S01-C08-P02',
|
||||
'S01-C08-P03',
|
||||
'S01-C08-P04',
|
||||
'S01-C08-P05',
|
||||
'S01-C08-P06',
|
||||
'S01-C08-P07',
|
||||
'S01-C08-P08',
|
||||
'S01-C09-P02',
|
||||
'S01-C09-P03',
|
||||
'S01-C09-P04',
|
||||
'S01-C09-P07',
|
||||
'S01-C09-P08',
|
||||
'S01-C10-P01',
|
||||
'S01-C10-P02',
|
||||
'S01-C10-P03',
|
||||
'S01-C10-P04',
|
||||
'S01-C10-P05',
|
||||
'S01-C10-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C10-P08',
|
||||
'S01-C11-P01',
|
||||
'S01-C11-P02',
|
||||
'S01-C11-P03',
|
||||
'S01-C11-P04',
|
||||
'S01-C11-P05',
|
||||
'S01-C11-P06',
|
||||
'S01-C11-P07',
|
||||
'S01-C11-P08',
|
||||
'S01-C12-P01',
|
||||
'S01-C12-P02',
|
||||
'S01-C12-P03',
|
||||
'S01-C12-P04',
|
||||
'S01-C12-P05',
|
||||
'S01-C12-P06',
|
||||
'S01-C12-P07',
|
||||
'S01-C12-P08',
|
||||
'S01-C13-P01',
|
||||
'S01-C13-P02',
|
||||
'S01-C13-P03',
|
||||
'S01-C13-P05',
|
||||
'S01-C13-P06',
|
||||
'S01-C13-P07',
|
||||
'S01-C13-P08',
|
||||
'S01-C14-P01',
|
||||
'S01-C14-P02',
|
||||
'S01-C14-P03',
|
||||
'S01-C14-P04',
|
||||
'S01-C14-P05',
|
||||
'S01-C14-P06',
|
||||
'S01-C14-P07',
|
||||
'S01-C14-P08',
|
||||
'S01-C15-P01',
|
||||
'S01-C15-P02',
|
||||
'S01-C15-P03',
|
||||
'S01-C15-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P06',
|
||||
'S01-C15-P07',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicAssetIdForPageId(pageId) {
|
||||
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
|
||||
if (!match) return ''
|
||||
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
|
||||
}
|
||||
|
||||
function registeredPageArtFor(page) {
|
||||
const assetId = comicAssetIdForPageId(page && page.pageId)
|
||||
const releaseAsset = assetId ? releaseAssets[assetId] : null
|
||||
return Boolean(
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
&& clean(releaseAsset.localSeed)
|
||||
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
|
||||
)
|
||||
}
|
||||
|
||||
function createPageArtVisual(page, chapter, runtimeTier) {
|
||||
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
|
||||
return {
|
||||
kind: 'page-art',
|
||||
asset: clean(page.illustrationAsset),
|
||||
subject: clean(page.actorName || page.headline || chapter.title),
|
||||
runtimeTier,
|
||||
formalReleaseEligible: formal,
|
||||
reviewStatus: formal
|
||||
? PLAYABLE_VISUAL_REVIEW_STATUS
|
||||
: PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: formal
|
||||
? 'art-release-gate-s01-usable'
|
||||
: 'registered-immutable-asset-experience-only',
|
||||
}
|
||||
}
|
||||
|
||||
function personByInstanceId(chapter, instanceId) {
|
||||
return (chapter.people || []).find(
|
||||
(person) => (
|
||||
person.instanceId === instanceId
|
||||
&& clean(person.portrait)
|
||||
),
|
||||
) || null
|
||||
}
|
||||
|
||||
function personByName(chapter, name) {
|
||||
const cleanName = clean(name)
|
||||
if (!cleanName) return null
|
||||
return (chapter.people || []).find(
|
||||
(person) => person.name === cleanName && clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function featuredPersonForPage(page, chapter) {
|
||||
const direct = personByInstanceId(chapter, page.actorInstanceId)
|
||||
if (direct) return direct
|
||||
|
||||
if (page.type === 'memory') {
|
||||
const memoryPerson = personByName(
|
||||
chapter,
|
||||
page.memoryCard && page.memoryCard.characterName,
|
||||
)
|
||||
if (memoryPerson) return memoryPerson
|
||||
}
|
||||
|
||||
if (page.type === 'emotion') {
|
||||
const emotionPerson = personByName(
|
||||
chapter,
|
||||
chapter.emotionMoment
|
||||
&& chapter.emotionMoment.character
|
||||
&& chapter.emotionMoment.character.name,
|
||||
)
|
||||
if (emotionPerson) return emotionPerson
|
||||
}
|
||||
|
||||
return (chapter.people || []).find(
|
||||
(person) => clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function createPlayableVisual(page, chapter) {
|
||||
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
|
||||
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
|
||||
}
|
||||
|
||||
if (registeredPageArtFor(page)) {
|
||||
return createPageArtVisual(
|
||||
page,
|
||||
chapter,
|
||||
RUNTIME_VISUAL_TIERS.PROVISIONAL,
|
||||
)
|
||||
}
|
||||
|
||||
if (page.type === 'event') {
|
||||
const portrait = clean(page.actorPortrait)
|
||||
if (portrait) {
|
||||
return {
|
||||
kind: 'actor-portrait',
|
||||
asset: portrait,
|
||||
subject: clean(page.actorName),
|
||||
actorInstanceId: clean(page.actorInstanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.actorName),
|
||||
evidenceLabel: clean(page.headline || '看看手边证据'),
|
||||
evidenceDetail: clean(
|
||||
page.secondaryCaption || page.caption || page.question,
|
||||
),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPerson = featuredPersonForPage(page, chapter)
|
||||
if (featuredPerson) {
|
||||
return {
|
||||
kind: 'chapter-character',
|
||||
asset: clean(featuredPerson.portrait),
|
||||
subject: clean(featuredPerson.name),
|
||||
actorInstanceId: clean(featuredPerson.instanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive last resort. The season currently never reaches this branch,
|
||||
// but if a future chapter lacks a portrait it still gets explicit evidence
|
||||
// instead of an unexplained empty room.
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.headline || chapter.title),
|
||||
evidenceLabel: clean(page.headline || chapter.title),
|
||||
evidenceDetail: clean(page.caption || chapter.narration),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
function attachPlayableVisuals(pageSequence, chapter) {
|
||||
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
|
||||
...page,
|
||||
playableVisual: createPlayableVisual(page, chapter),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORMAL_PAGE_ART_IDS,
|
||||
PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
RUNTIME_VISUAL_TIERS,
|
||||
attachPlayableVisuals,
|
||||
createPlayableVisual,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Generated package-local production comic data. Do not hand-edit.
|
||||
module.exports = {}
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* 发布固定资产清单 v1
|
||||
*
|
||||
* 约束:
|
||||
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
|
||||
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
|
||||
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
|
||||
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
|
||||
*/
|
||||
|
||||
const RELEASE_ASSET_MANIFEST_VERSION = 1
|
||||
|
||||
const releaseAssets = {
|
||||
'comic.s01.c01.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
|
||||
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
|
||||
},
|
||||
'comic.s01.c01.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
|
||||
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
|
||||
},
|
||||
'comic.s01.c01.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
|
||||
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
|
||||
},
|
||||
'comic.s01.c01.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
|
||||
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
|
||||
},
|
||||
'comic.s01.c01.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
|
||||
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
|
||||
},
|
||||
'comic.s01.c01.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
|
||||
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
|
||||
},
|
||||
'comic.s01.c01.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
|
||||
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
|
||||
},
|
||||
'comic.s01.c01.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
|
||||
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
|
||||
},
|
||||
'comic.s01.c02.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
|
||||
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
|
||||
},
|
||||
'comic.s01.c02.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
|
||||
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
|
||||
},
|
||||
'comic.s01.c02.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
|
||||
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
|
||||
},
|
||||
'comic.s01.c02.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
|
||||
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
|
||||
},
|
||||
'comic.s01.c02.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
|
||||
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
|
||||
},
|
||||
'comic.s01.c02.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
|
||||
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
|
||||
},
|
||||
'comic.s01.c02.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
|
||||
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
|
||||
},
|
||||
'comic.s01.c02.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
|
||||
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
|
||||
},
|
||||
'comic.s01.c03.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
|
||||
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
|
||||
},
|
||||
'comic.s01.c03.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
|
||||
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
|
||||
},
|
||||
'comic.s01.c03.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
|
||||
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
|
||||
},
|
||||
'comic.s01.c03.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
|
||||
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
|
||||
},
|
||||
'comic.s01.c03.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
|
||||
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
|
||||
},
|
||||
'comic.s01.c03.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
|
||||
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
|
||||
},
|
||||
'comic.s01.c03.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
|
||||
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
|
||||
},
|
||||
'comic.s01.c03.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
|
||||
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
|
||||
},
|
||||
'comic.s01.c04.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
|
||||
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
|
||||
},
|
||||
'comic.s01.c04.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
|
||||
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
|
||||
},
|
||||
'comic.s01.c04.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
|
||||
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
|
||||
},
|
||||
'comic.s01.c04.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
|
||||
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
|
||||
},
|
||||
'comic.s01.c04.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
|
||||
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
|
||||
},
|
||||
'comic.s01.c04.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
|
||||
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
|
||||
},
|
||||
'comic.s01.c04.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
|
||||
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
|
||||
},
|
||||
'comic.s01.c04.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
|
||||
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
|
||||
},
|
||||
'comic.s01.c05.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
|
||||
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
|
||||
},
|
||||
'comic.s01.c05.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
|
||||
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
|
||||
},
|
||||
'comic.s01.c05.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
|
||||
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
|
||||
},
|
||||
'comic.s01.c05.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
|
||||
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
|
||||
},
|
||||
'comic.s01.c05.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
|
||||
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
|
||||
},
|
||||
'comic.s01.c05.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
|
||||
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
|
||||
},
|
||||
'comic.s01.c05.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
|
||||
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
|
||||
},
|
||||
'comic.s01.c05.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
|
||||
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
|
||||
},
|
||||
'comic.s01.c06.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
|
||||
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
|
||||
},
|
||||
'comic.s01.c06.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
|
||||
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
|
||||
},
|
||||
'comic.s01.c06.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
|
||||
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
|
||||
},
|
||||
'comic.s01.c06.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
|
||||
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
|
||||
},
|
||||
'comic.s01.c06.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
|
||||
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
|
||||
},
|
||||
'comic.s01.c06.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
|
||||
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
|
||||
},
|
||||
'comic.s01.c06.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
|
||||
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
|
||||
},
|
||||
'comic.s01.c06.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
|
||||
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
|
||||
},
|
||||
'comic.s01.c07.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
|
||||
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
|
||||
},
|
||||
'comic.s01.c07.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
|
||||
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
|
||||
},
|
||||
'comic.s01.c07.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
|
||||
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
|
||||
},
|
||||
'comic.s01.c07.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
|
||||
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
|
||||
},
|
||||
'comic.s01.c07.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
|
||||
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
|
||||
},
|
||||
'comic.s01.c07.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
|
||||
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
|
||||
},
|
||||
'comic.s01.c07.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
|
||||
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
|
||||
},
|
||||
'comic.s01.c07.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
|
||||
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
|
||||
},
|
||||
'comic.s01.c08.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
|
||||
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
|
||||
},
|
||||
'comic.s01.c08.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
|
||||
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
|
||||
},
|
||||
'comic.s01.c08.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
|
||||
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
|
||||
},
|
||||
'comic.s01.c08.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
|
||||
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
|
||||
},
|
||||
'comic.s01.c08.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
|
||||
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
|
||||
},
|
||||
'comic.s01.c08.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
|
||||
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
|
||||
},
|
||||
'comic.s01.c08.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
|
||||
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
|
||||
},
|
||||
'comic.s01.c08.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
|
||||
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
|
||||
},
|
||||
'comic.s01.c09.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
|
||||
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
|
||||
},
|
||||
'comic.s01.c09.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
|
||||
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
|
||||
},
|
||||
'comic.s01.c09.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
|
||||
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
|
||||
},
|
||||
'comic.s01.c09.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
|
||||
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
|
||||
},
|
||||
'comic.s01.c09.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
|
||||
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
|
||||
},
|
||||
'comic.s01.c09.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
|
||||
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
|
||||
},
|
||||
'comic.s01.c09.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
|
||||
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
|
||||
},
|
||||
'comic.s01.c09.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
|
||||
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
|
||||
},
|
||||
'comic.s01.c10.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
|
||||
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
|
||||
},
|
||||
'comic.s01.c10.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
|
||||
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
|
||||
},
|
||||
'comic.s01.c10.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
|
||||
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
|
||||
},
|
||||
'comic.s01.c10.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
|
||||
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
|
||||
},
|
||||
'comic.s01.c10.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
|
||||
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
|
||||
},
|
||||
'comic.s01.c10.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
|
||||
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
|
||||
},
|
||||
'comic.s01.c10.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
|
||||
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
|
||||
},
|
||||
'comic.s01.c10.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
|
||||
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
|
||||
},
|
||||
'comic.s01.c11.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
|
||||
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
|
||||
},
|
||||
'comic.s01.c11.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
|
||||
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
|
||||
},
|
||||
'comic.s01.c11.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
|
||||
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
|
||||
},
|
||||
'comic.s01.c11.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
|
||||
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
|
||||
},
|
||||
'comic.s01.c11.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
|
||||
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
|
||||
},
|
||||
'comic.s01.c11.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
|
||||
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
|
||||
},
|
||||
'comic.s01.c11.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
|
||||
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
|
||||
},
|
||||
'comic.s01.c11.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
|
||||
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
|
||||
},
|
||||
'comic.s01.c12.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
|
||||
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
|
||||
},
|
||||
'comic.s01.c12.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
|
||||
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
|
||||
},
|
||||
'comic.s01.c12.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
|
||||
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
|
||||
},
|
||||
'comic.s01.c12.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
|
||||
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
|
||||
},
|
||||
'comic.s01.c12.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
|
||||
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
|
||||
},
|
||||
'comic.s01.c12.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
|
||||
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
|
||||
},
|
||||
'comic.s01.c12.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
|
||||
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
|
||||
},
|
||||
'comic.s01.c12.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
|
||||
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
|
||||
},
|
||||
'comic.s01.c13.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
|
||||
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
|
||||
},
|
||||
'comic.s01.c13.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
|
||||
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
|
||||
},
|
||||
'comic.s01.c13.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
|
||||
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
|
||||
},
|
||||
'comic.s01.c13.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
|
||||
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
|
||||
},
|
||||
'comic.s01.c13.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
|
||||
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
|
||||
},
|
||||
'comic.s01.c13.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
|
||||
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
|
||||
},
|
||||
'comic.s01.c13.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
|
||||
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
|
||||
},
|
||||
'comic.s01.c13.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
|
||||
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
|
||||
},
|
||||
'comic.s01.c14.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
|
||||
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
|
||||
},
|
||||
'comic.s01.c14.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
|
||||
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
|
||||
},
|
||||
'comic.s01.c14.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
|
||||
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
|
||||
},
|
||||
'comic.s01.c14.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
|
||||
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
|
||||
},
|
||||
'comic.s01.c14.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
|
||||
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
|
||||
},
|
||||
'comic.s01.c14.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
|
||||
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
|
||||
},
|
||||
'comic.s01.c14.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
|
||||
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
|
||||
},
|
||||
'comic.s01.c14.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
|
||||
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
|
||||
},
|
||||
'comic.s01.c15.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
|
||||
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
|
||||
},
|
||||
'comic.s01.c15.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
|
||||
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
|
||||
},
|
||||
'comic.s01.c15.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
|
||||
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
|
||||
},
|
||||
'comic.s01.c15.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
|
||||
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
|
||||
},
|
||||
'comic.s01.c15.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
|
||||
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
|
||||
},
|
||||
'comic.s01.c15.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
|
||||
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
|
||||
},
|
||||
'comic.s01.c15.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
|
||||
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
|
||||
},
|
||||
'comic.s01.c15.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
|
||||
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
|
||||
},
|
||||
'character.female-cook': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/female-cook.jpg',
|
||||
remotePath: 'characters/female-cook.c56420b2c632.jpg',
|
||||
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
|
||||
},
|
||||
'character.lele': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lele.jpg',
|
||||
remotePath: 'characters/lele.807f5232b1e8.jpg',
|
||||
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
|
||||
},
|
||||
'character.lin-xiulan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lin-xiulan.jpg',
|
||||
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
|
||||
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
|
||||
},
|
||||
'character.qin-xiaoman': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-xiaoman.jpg',
|
||||
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
|
||||
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
|
||||
},
|
||||
'character.qin-zhicheng': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-zhicheng.jpg',
|
||||
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
|
||||
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
|
||||
},
|
||||
'character.tang-mingyuan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-mingyuan.jpg',
|
||||
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
|
||||
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
|
||||
},
|
||||
'character.tang-shouan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-shouan.jpg',
|
||||
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
|
||||
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
|
||||
},
|
||||
'character.xiaozhen': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/xiaozhen.jpg',
|
||||
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
|
||||
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
|
||||
},
|
||||
'character.zhao-jianguo': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/zhao-jianguo.jpg',
|
||||
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
|
||||
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
|
||||
},
|
||||
'scene.gui-xiang.1978': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
|
||||
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
|
||||
},
|
||||
'scene.gui-xiang.1995': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
|
||||
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
|
||||
},
|
||||
'scene.gui-xiang.1998': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
|
||||
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
|
||||
},
|
||||
'scene.gui-xiang.2001': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
|
||||
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
|
||||
},
|
||||
'scene.gui-xiang.2003': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
|
||||
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
|
||||
},
|
||||
'scene.gui-xiang.2008': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
|
||||
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
|
||||
},
|
||||
'scene.gui-xiang.2026': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
|
||||
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
|
||||
},
|
||||
'audio.cast-audition.v1': {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'audition',
|
||||
localSeed: '',
|
||||
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
|
||||
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
|
||||
// The reader no longer maps them as page narration; C01 uses full-page tracks
|
||||
// through the two package-local audio player pages instead.
|
||||
for (const cueId of [
|
||||
'S01-C01-MS007','S01-C01-MS010',
|
||||
]) {
|
||||
const file = `${cueId}.mp3`
|
||||
const sha256 = {
|
||||
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
|
||||
}[cueId]
|
||||
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
|
||||
}
|
||||
|
||||
const audioC02Sha256 = {
|
||||
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
|
||||
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
|
||||
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
|
||||
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
|
||||
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
|
||||
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
|
||||
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
|
||||
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
|
||||
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
|
||||
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
|
||||
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
|
||||
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
|
||||
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
|
||||
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
|
||||
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
|
||||
}
|
||||
|
||||
const audioC03Sha256 = {
|
||||
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
|
||||
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
|
||||
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
|
||||
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
|
||||
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
|
||||
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
|
||||
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
|
||||
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
|
||||
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
|
||||
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
|
||||
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
|
||||
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
|
||||
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
|
||||
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
|
||||
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
|
||||
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
|
||||
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
|
||||
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
|
||||
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
|
||||
}
|
||||
|
||||
// C02/C03 cue files are retained only as rollback/source material. A single
|
||||
// cue is not a complete page track, so these assets must not pass the runtime
|
||||
// audio gate or appear as “试听本页”.
|
||||
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
|
||||
for (const [cueId, sha256] of Object.entries(audioHashes)) {
|
||||
const file = `${cueId}.mp3`
|
||||
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'reserved',
|
||||
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
|
||||
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RELEASE_ASSET_MANIFEST_VERSION,
|
||||
releaseAssets,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* C02-C15 remote full-page audio manifest v1.
|
||||
*
|
||||
* Import contract for a future reviewed batch:
|
||||
*
|
||||
* 'S01-C09-P05': Object.freeze({
|
||||
* pageId: 'S01-C09-P05',
|
||||
* chapterNumber: 9,
|
||||
* pageNumber: 5,
|
||||
* assetId: 'audio.page.s01.c09.p05',
|
||||
* durationSeconds: 42.1,
|
||||
* reviewStatus: 'approved',
|
||||
* })
|
||||
*
|
||||
* The matching releaseAssetManifest entry must independently be an approved,
|
||||
* remote-only, immutable hash-named audio asset. Automated QA, pending human
|
||||
* listening, and missing CDN records must never be relabelled as approved.
|
||||
*/
|
||||
|
||||
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
|
||||
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
// Deliberately empty until full-page tracks pass the required human reviews
|
||||
// and their immutable CDN objects are available. This is a release gate, not
|
||||
// a placeholder that the UI may treat as playable.
|
||||
const remotePageAudioPages = Object.freeze({})
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function remotePageAudioAssetId(pageId) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return ''
|
||||
return `audio.page.s01.c${match[1]}.p${match[2]}`
|
||||
}
|
||||
|
||||
function getApprovedRemotePageAudio(
|
||||
pageId,
|
||||
releaseAssets = {},
|
||||
pageManifest = remotePageAudioPages,
|
||||
) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return null
|
||||
|
||||
const entry = pageManifest && pageManifest[stablePageId]
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const chapterNumber = Number(match[1])
|
||||
const pageNumber = Number(match[2])
|
||||
const assetId = remotePageAudioAssetId(stablePageId)
|
||||
const durationSeconds = Number(entry.durationSeconds)
|
||||
if (
|
||||
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| entry.pageId !== stablePageId
|
||||
|| Number(entry.chapterNumber) !== chapterNumber
|
||||
|| Number(entry.pageNumber) !== pageNumber
|
||||
|| entry.assetId !== assetId
|
||||
|| !Number.isFinite(durationSeconds)
|
||||
|| durationSeconds <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const asset = releaseAssets && releaseAssets[assetId]
|
||||
const sha256 = asset && typeof asset.sha256 === 'string'
|
||||
? asset.sha256
|
||||
: ''
|
||||
const remotePath = asset && asset.remotePath
|
||||
const expectedRemotePath = (
|
||||
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
|
||||
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
|
||||
)
|
||||
if (
|
||||
!asset
|
||||
|| asset.kind !== 'audio'
|
||||
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| asset.localSeed !== ''
|
||||
|| !SHA256_PATTERN.test(sha256)
|
||||
|| typeof remotePath !== 'string'
|
||||
|| remotePath !== expectedRemotePath
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...entry, assetId }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REMOTE_PAGE_AUDIO_ID_PATTERN,
|
||||
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
|
||||
getApprovedRemotePageAudio,
|
||||
remotePageAudioAssetId,
|
||||
remotePageAudioPages,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "唐侦探",
|
||||
"pageOrientation": "landscape",
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
<view
|
||||
wx:if="{{chapter}}"
|
||||
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
|
||||
style="{{pageStyle}}"
|
||||
>
|
||||
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
|
||||
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
|
||||
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录">‹ 目录</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
|
||||
class="comic-top-previous-button"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一张画"
|
||||
>‹</button>
|
||||
<view class="comic-bookmark">
|
||||
<text class="comic-bookmark-label">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
|
||||
</text>
|
||||
<text class="comic-page-count">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
</view>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
|
||||
bindtap="openTextReading"
|
||||
aria-label="本页可以直接看文字"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text class="comic-page-audio-label">直接看文字</text>
|
||||
<text class="comic-page-audio-meta">按页慢慢看</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
|
||||
disabled="{{audioLoading}}"
|
||||
bindtap="toggleComicPageAudio"
|
||||
data-audio-cue-count="{{currentPage.audioCueCount}}"
|
||||
aria-label="本页语音播放控制"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">
|
||||
{{audioPlaying ? 'Ⅱ' : '▶'}}
|
||||
</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
|
||||
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
|
||||
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
|
||||
<text wx:else class="comic-page-audio-label">听一听</text>
|
||||
<text class="comic-page-audio-meta">
|
||||
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
|
||||
</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
|
||||
class="comic-font-button"
|
||||
bindtap="toggleFont"
|
||||
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
|
||||
>
|
||||
{{fontScale === 'large' ? '大字' : '标准字'}}
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
|
||||
class="c1-front-action-button"
|
||||
bindtap="openCurrentFrontAction"
|
||||
aria-label="{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
|
||||
: '点一下,翻到背面'}}"
|
||||
>
|
||||
{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '再看背面' : '点点人物')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '看看章末' : '听听心里话')
|
||||
: '翻到背面'}}
|
||||
</button>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view class="comic-reader">
|
||||
<block wx:if="{{storyFirstComicMode}}">
|
||||
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
|
||||
<block wx:if="{{chapterOneBackOpen}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">
|
||||
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边回声</text>
|
||||
<text>{{currentPage.memoryCard.tableEcho}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">带回生活里</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
<view class="c1-memory-secondary-actions">
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>看看生活观察</button>
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>发给家人聊聊</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{chapterOneReviewEvent.evidence}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边一句话</text>
|
||||
<text>“{{currentPage.shortDialogue}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text
|
||||
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
|
||||
class="c1-scroll-hint"
|
||||
>上划接着看 ↑</text>
|
||||
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
|
||||
</view>
|
||||
<view wx:else class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button
|
||||
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
|
||||
class="c1-back-button primary"
|
||||
bindtap="startChapterOneDecisionFromBack"
|
||||
>聊聊这事</button>
|
||||
<button
|
||||
wx:else
|
||||
class="c1-back-button primary"
|
||||
bindtap="nextChapterOnePageFromBack"
|
||||
>翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边回声</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">没有标准答案</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{emotionResult.feedback}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">这一桌记住了</text>
|
||||
<text>{{emotionResult.echo}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
|
||||
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
|
||||
<view class="c1-interaction-paper c1-emotion-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边一句话</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
|
||||
<text class="c1-tray-question">{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="c1-natural-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>{{item.text}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view class="c1-front-stage">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-front-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="c1-front-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="c1-front-grain"></view>
|
||||
|
||||
<button
|
||||
wx:if="{{!frontControlsRevealed}}"
|
||||
class="c1-front-reveal-gate"
|
||||
bindtap="revealCurrentFrontControls"
|
||||
aria-label="轻点画面,显示阅读按钮"
|
||||
></button>
|
||||
|
||||
<block wx:else>
|
||||
<button
|
||||
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
|
||||
class="c1-person-hotspot"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
|
||||
><view class="c1-hotspot-ring"></view></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="翻到这一幕的背面"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneEmotion"
|
||||
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:else
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="轻触画面,翻到这一页背面"
|
||||
></button>
|
||||
</block>
|
||||
|
||||
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
|
||||
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion'}}">
|
||||
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="special-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="special-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="emotion-panel">
|
||||
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="emotion-context">
|
||||
<text class="emotion-context-kicker">听听这一桌心里话</text>
|
||||
<text class="emotion-context-title">{{currentPage.headline}}</text>
|
||||
<text class="emotion-context-caption">{{currentPage.caption}}</text>
|
||||
</view>
|
||||
<text class="emotion-prompt">{{currentPage.prompt}}</text>
|
||||
<text
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-note"
|
||||
>没有标准答案,选您更愿意说的话。</text>
|
||||
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
|
||||
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
|
||||
<view class="table-echo">
|
||||
<text class="echo-label">桌边回声</text>
|
||||
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-choice-dock"
|
||||
>
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="emotion-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>
|
||||
<text class="choice-number">{{index + 1}}</text>
|
||||
<text>{{item.text}}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
|
||||
<button
|
||||
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{emotionResult || chapterFinished}}"
|
||||
class="special-page-button primary"
|
||||
bindtap="finishComicEmotion"
|
||||
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
|
||||
>
|
||||
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.isSeasonClose}}">
|
||||
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="season-close-body">
|
||||
<view class="season-close-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="season-close-main-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="season-close-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view
|
||||
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
|
||||
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
|
||||
aria-label="{{currentPage.detailInset.label}}近景"
|
||||
>
|
||||
<image
|
||||
class="season-close-detail-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{currentPage.detailInset.imageMode}}"
|
||||
style="{{currentPage.detailInset.imageStyle}}"
|
||||
></image>
|
||||
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="season-close-copy"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="season-close-copy-sheet">
|
||||
<text class="season-close-kicker">第一季 · 季终</text>
|
||||
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
|
||||
<view class="season-close-memory-card">
|
||||
<text class="season-close-card-label">桂香记忆卡</text>
|
||||
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
|
||||
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="season-close-life-line">
|
||||
<text class="season-close-line-label">今天可以这样做</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<view class="season-close-family-line">
|
||||
<text class="season-close-line-label">带回家聊一聊</text>
|
||||
<text>“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="memory-actions season-close-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="第一季读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'memory'}}">
|
||||
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
|
||||
<view class="memory-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="memory-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="memory-panel">
|
||||
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
|
||||
<view class="memory-paper-content">
|
||||
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
|
||||
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
|
||||
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
|
||||
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
|
||||
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
|
||||
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
|
||||
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
|
||||
</button>
|
||||
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="memory-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{chapterNumber >= chapterCount}}"
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="全书读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
<button
|
||||
wx:else
|
||||
class="memory-action-cell primary"
|
||||
bindtap="nextChapter"
|
||||
aria-label="翻到下一回"
|
||||
>
|
||||
<text class="memory-action-visible">翻到下一回</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view
|
||||
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
|
||||
bindtouchstart="onComicTouchStart"
|
||||
bindtouchend="onComicTouchEnd"
|
||||
>
|
||||
<view class="comic-art-column">
|
||||
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{comicPageImageMode}}"
|
||||
style="{{comicPageImageStyle}}"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="comic-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
<view
|
||||
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
|
||||
class="comic-evidence-composite"
|
||||
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
|
||||
>
|
||||
<text class="comic-evidence-kicker">手边证据</text>
|
||||
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
|
||||
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
|
||||
</view>
|
||||
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
|
||||
class="comic-actor-hotspot is-quiet"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
|
||||
></button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
|
||||
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
|
||||
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
|
||||
<view class="comic-cover-caption-heading">
|
||||
<text class="comic-caption-kicker">{{chapter.year}}</text>
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
</view>
|
||||
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
<view class="comic-ensemble-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
|
||||
<view class="comic-event-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
<view class="comic-event-cue">
|
||||
<text
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight}}"
|
||||
class="comic-outside-actor-button"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
|
||||
<text class="seen-chip">看见了</text>
|
||||
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
wx:if="{{currentInteractionEnabled && compactHeight}}"
|
||||
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
|
||||
>
|
||||
<text
|
||||
wx:if="{{currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
class="comic-outside-actor-button comic-outside-actor-dock"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="comic-page-nav">
|
||||
<button
|
||||
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
<button
|
||||
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="nextComicPage"
|
||||
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
|
||||
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">
|
||||
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
|
||||
</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
|
||||
<view
|
||||
class="text-reading-dialog paper-panel"
|
||||
aria-role="dialog"
|
||||
aria-label="本页大字文字"
|
||||
>
|
||||
<button
|
||||
class="dialog-close"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="关闭文字阅读"
|
||||
>×</button>
|
||||
<text class="text-reading-kicker">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
|
||||
</text>
|
||||
<scroll-view
|
||||
class="text-reading-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="text-reading-sheet">
|
||||
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
|
||||
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">这一页想和您聊</text>
|
||||
<text>{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">看看画里发生了什么</text>
|
||||
<text>{{currentPage.question}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<button
|
||||
class="text-reading-return"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="回到连环画"
|
||||
>回到画里</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
|
||||
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">画里的线索</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
|
||||
<text class="c1-tray-question">{{activeEvent.question}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{activeEvent.choiceOptions}}"
|
||||
wx:key="value"
|
||||
class="c1-natural-choice"
|
||||
data-value="{{item.value}}"
|
||||
bindtap="answer"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">再看看画里</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel c1-retry-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
|
||||
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
|
||||
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
|
||||
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-paper c1-event-result-back">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">这下有眉目了</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
|
||||
<text class="c1-back-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<block wx:else>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
<text class="c1-back-reason">{{activeEvent.reason}}</text>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text class="c1-scroll-hint">上划接着看 ↑</text>
|
||||
<view class="c1-back-footer">
|
||||
<button
|
||||
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="c1-back-button secondary"
|
||||
aria-label="回头再看一遍画面"
|
||||
bindtap="returnFromChapterOneDecision"
|
||||
>回去看画</button>
|
||||
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
|
||||
<view
|
||||
class="decision-dialog paper-panel comic-decision"
|
||||
aria-role="dialog"
|
||||
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
|
||||
>
|
||||
<button
|
||||
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="dialog-close"
|
||||
bindtap="closeDecision"
|
||||
aria-label="回到画里"
|
||||
>×</button>
|
||||
|
||||
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="character-story-inner">
|
||||
<view class="comic-focus-visual">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-focus-scene"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
style="{{comicFocusImageStyle}}"
|
||||
></image>
|
||||
<view class="comic-focus-shade"></view>
|
||||
<view class="comic-focus-name">
|
||||
<text>{{activePerson.eraLabel}} · 画中人物</text>
|
||||
<text class="focus-name">{{activeEvent.actorName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="story-label">这一幕发生了什么</text>
|
||||
<text class="action-description">{{activeEvent.actionDescription}}</text>
|
||||
|
||||
<view class="evidence-box">
|
||||
<text class="story-label">手边证据</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
|
||||
<view class="speech-box">
|
||||
<text class="story-label">桌边说话</text>
|
||||
<text>{{activeEvent.speech}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
|
||||
<scroll-view
|
||||
class="judgement-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{decisionResultNeedsScroll}}"
|
||||
>
|
||||
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
|
||||
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
|
||||
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
|
||||
|
||||
<view wx:if="{{resultMode === ''}}" class="judgement-options">
|
||||
<button class="judgement-button observe" data-value="caution" bindtap="answer">
|
||||
<text class="judgement-icon">A</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">先看看现场细节</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="judgement-button continue" data-value="safer" bindtap="answer">
|
||||
<text class="judgement-icon">B</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">继续按画里的做法</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
|
||||
<text class="result-title">没关系,再看看画里的细节。</text>
|
||||
<text>{{activeEvent.retryHint}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
|
||||
<text class="result-title">这处线索对上了。</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
|
||||
<text class="advice-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<view wx:else class="action-advice">
|
||||
<text class="advice-label">现在可以怎么做</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
|
||||
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
|
||||
</view>
|
||||
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="openResultDetail">
|
||||
再看不同情况怎么办
|
||||
</button>
|
||||
</view>
|
||||
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
|
||||
回到画里
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
|
||||
contractId: 'story-first-three-frame-v1',
|
||||
columns: Object.freeze({
|
||||
art: 3,
|
||||
content: 2,
|
||||
}),
|
||||
frames: Object.freeze({
|
||||
front: Object.freeze({
|
||||
imageMode: 'aspectFit',
|
||||
initialControls: 'none',
|
||||
revealGesture: 'single-tap',
|
||||
revealedControls: 'topbar-only',
|
||||
}),
|
||||
back: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
}),
|
||||
interaction: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
choiceCount: 2,
|
||||
choiceDirection: 'vertical',
|
||||
}),
|
||||
}),
|
||||
minimumControlPx: 48,
|
||||
compactHeightMaxPx: 620,
|
||||
fontModes: Object.freeze({
|
||||
standard: 'large',
|
||||
xlarge: 'xlarge',
|
||||
}),
|
||||
audioSlot: Object.freeze({
|
||||
placement: 'unified-topbar',
|
||||
minimumHeightPx: 48,
|
||||
textModeLabel: '直接看文字',
|
||||
readyLabel: '听一听',
|
||||
playingLabel: '暂停',
|
||||
replayLabel: '重听',
|
||||
usesPageAudioCueIds: true,
|
||||
}),
|
||||
artOverlayPolicy: Object.freeze({
|
||||
event: 'transparent-actor-hotspot-only',
|
||||
emotion: 'transparent-actor-hotspot-only',
|
||||
}),
|
||||
})
|
||||
|
||||
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
|
||||
cover: 'chapter-ensemble',
|
||||
ensemble: 'chapter-ensemble',
|
||||
event: 'actor-event',
|
||||
emotion: 'emotion-interaction',
|
||||
memory: 'memory-cliffhanger',
|
||||
})
|
||||
|
||||
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
|
||||
'S01-C01': 'front-back-comic-v1',
|
||||
'S01-C02': 'front-back-comic-v1',
|
||||
'S01-C03': 'front-back-comic-v1',
|
||||
'S01-C04': 'front-back-comic-v1',
|
||||
'S01-C05': 'front-back-comic-v1',
|
||||
'S01-C06': 'front-back-comic-v1',
|
||||
'S01-C07': 'front-back-comic-v1',
|
||||
'S01-C08': 'front-back-comic-v1',
|
||||
'S01-C09': 'front-back-comic-v1',
|
||||
'S01-C10': 'front-back-comic-v1',
|
||||
'S01-C11': 'front-back-comic-v1',
|
||||
'S01-C12': 'front-back-comic-v1',
|
||||
'S01-C13': 'front-back-comic-v1',
|
||||
'S01-C14': 'front-back-comic-v1',
|
||||
'S01-C15': 'front-back-comic-v1',
|
||||
})
|
||||
|
||||
function getComicPresentationMode(chapterId) {
|
||||
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|
||||
|| 'legacy-60-40-v1'
|
||||
}
|
||||
|
||||
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
|
||||
'S01-C09-P03': Object.freeze({
|
||||
layoutVariant: 'standard-landscape-60-40',
|
||||
behavior: 'key-safe-actor-hotspot',
|
||||
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
|
||||
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
|
||||
}),
|
||||
})
|
||||
|
||||
// These pages keep the full reviewed frame and add one read-only crop from
|
||||
// the same asset as a second comic panel. This is an art-reading aid, not a
|
||||
// new interaction or a page-layout exception.
|
||||
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
|
||||
'S01-C04-P04',
|
||||
'S01-C09-P01',
|
||||
'S01-C09-P05',
|
||||
'S01-C09-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C13-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function allowsFixedDetailInset(pageId) {
|
||||
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
|
||||
String(pageId || ''),
|
||||
) >= 0
|
||||
}
|
||||
|
||||
function getComicPageLayoutException(pageId) {
|
||||
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
|
||||
}
|
||||
|
||||
function resolveComicPageLayout(pageId, type) {
|
||||
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
|
||||
const exception = getComicPageLayoutException(pageId)
|
||||
return {
|
||||
templateKey,
|
||||
layoutVariant: exception
|
||||
? exception.layoutVariant
|
||||
: 'standard-landscape-60-40',
|
||||
layoutExceptionId: exception ? String(pageId) : '',
|
||||
layoutExceptionBehavior: exception ? exception.behavior : '',
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_READER_LAYOUT_CONTRACT,
|
||||
COMIC_PRESENTATION_BY_CHAPTER,
|
||||
COMIC_PAGE_TEMPLATE_BY_TYPE,
|
||||
COMIC_PAGE_LAYOUT_EXCEPTIONS,
|
||||
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
|
||||
allowsFixedDetailInset,
|
||||
getComicPresentationMode,
|
||||
getComicPageLayoutException,
|
||||
resolveComicPageLayout,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
const CACHE_INDEX_VERSION = 2
|
||||
const CACHE_FOLDER = 'tang-detective-assets-v2'
|
||||
const CACHE_INDEX_FILE = 'index.json'
|
||||
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_MAX_ENTRIES = 32
|
||||
|
||||
const SHA256_ROUND_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function utf8Bytes(value) {
|
||||
const encoded = encodeURIComponent(String(value || ''))
|
||||
const bytes = []
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] === '%') {
|
||||
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
|
||||
index += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
if (typeof value === 'string') return utf8Bytes(value)
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
throw new Error('unsupported SHA-256 input')
|
||||
}
|
||||
|
||||
function rotateRight(value, bits) {
|
||||
return (value >>> bits) | (value << (32 - bits))
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
const bytes = toBytes(value)
|
||||
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||
const message = new Uint8Array(paddedLength)
|
||||
message.set(bytes)
|
||||
message[bytes.length] = 0x80
|
||||
const bitLength = bytes.length * 8
|
||||
const highBits = Math.floor(bitLength / 0x100000000)
|
||||
const lowBits = bitLength >>> 0
|
||||
const lengthOffset = paddedLength - 8
|
||||
message[lengthOffset] = (highBits >>> 24) & 0xff
|
||||
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
|
||||
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
|
||||
message[lengthOffset + 3] = highBits & 0xff
|
||||
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
|
||||
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
|
||||
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
|
||||
message[lengthOffset + 7] = lowBits & 0xff
|
||||
|
||||
const hash = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]
|
||||
const words = new Uint32Array(64)
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const start = offset + index * 4
|
||||
words[index] = (
|
||||
(message[start] << 24)
|
||||
| (message[start + 1] << 16)
|
||||
| (message[start + 2] << 8)
|
||||
| message[start + 3]
|
||||
) >>> 0
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const word15 = words[index - 15]
|
||||
const word2 = words[index - 2]
|
||||
const sigma0 = (
|
||||
rotateRight(word15, 7)
|
||||
^ rotateRight(word15, 18)
|
||||
^ (word15 >>> 3)
|
||||
)
|
||||
const sigma1 = (
|
||||
rotateRight(word2, 17)
|
||||
^ rotateRight(word2, 19)
|
||||
^ (word2 >>> 10)
|
||||
)
|
||||
words[index] = (
|
||||
words[index - 16]
|
||||
+ sigma0
|
||||
+ words[index - 7]
|
||||
+ sigma1
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
let a = hash[0]
|
||||
let b = hash[1]
|
||||
let c = hash[2]
|
||||
let d = hash[3]
|
||||
let e = hash[4]
|
||||
let f = hash[5]
|
||||
let g = hash[6]
|
||||
let h = hash[7]
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
||||
const choice = (e & f) ^ (~e & g)
|
||||
const temp1 = (
|
||||
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
|
||||
) >>> 0
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (sum0 + majority) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0
|
||||
hash[1] = (hash[1] + b) >>> 0
|
||||
hash[2] = (hash[2] + c) >>> 0
|
||||
hash[3] = (hash[3] + d) >>> 0
|
||||
hash[4] = (hash[4] + e) >>> 0
|
||||
hash[5] = (hash[5] + f) >>> 0
|
||||
hash[6] = (hash[6] + g) >>> 0
|
||||
hash[7] = (hash[7] + h) >>> 0
|
||||
}
|
||||
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||
}
|
||||
|
||||
function constantTimeEqualHex(left, right) {
|
||||
const leftValue = String(left || '').toLowerCase()
|
||||
const rightValue = String(right || '').toLowerCase()
|
||||
const length = Math.max(leftValue.length, rightValue.length)
|
||||
let difference = leftValue.length ^ rightValue.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
|
||||
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
|
||||
difference |= leftCode ^ rightCode
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function joinPath(left, right) {
|
||||
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function normalizeIndex(value) {
|
||||
if (
|
||||
!value
|
||||
|| value.version !== CACHE_INDEX_VERSION
|
||||
|| !value.entries
|
||||
|| typeof value.entries !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: CACHE_INDEX_VERSION,
|
||||
entries: {},
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function callFs(fs, method, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs || typeof fs[method] !== 'function') {
|
||||
reject(new Error(`fs.${method} unavailable`))
|
||||
return
|
||||
}
|
||||
fs[method]({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createAssetPlatformFacade() {
|
||||
if (typeof wx === 'undefined') return null
|
||||
const env = Object.freeze({
|
||||
USER_DATA_PATH: String(
|
||||
wx.env && wx.env.USER_DATA_PATH || '',
|
||||
),
|
||||
})
|
||||
return Object.freeze({
|
||||
downloadFile(options) {
|
||||
if (typeof wx.downloadFile !== 'function') {
|
||||
if (options && typeof options.fail === 'function') {
|
||||
options.fail(new Error('wx.downloadFile unavailable'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return wx.downloadFile(options)
|
||||
},
|
||||
getFileSystemManager() {
|
||||
if (typeof wx.getFileSystemManager !== 'function') return null
|
||||
return wx.getFileSystemManager()
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
function download(assetPlatform, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
|
||||
reject(new Error('wx.downloadFile unavailable'))
|
||||
return
|
||||
}
|
||||
assetPlatform.downloadFile({
|
||||
url,
|
||||
success(result) {
|
||||
if (
|
||||
result
|
||||
&& result.statusCode >= 200
|
||||
&& result.statusCode < 300
|
||||
&& result.tempFilePath
|
||||
) {
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
reject(new Error(`download status ${result && result.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function safeCacheName(assetId, asset) {
|
||||
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
|
||||
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
|
||||
}
|
||||
|
||||
function createAssetManager(options = {}) {
|
||||
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
|
||||
const manifest = options.manifest || {}
|
||||
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
|
||||
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
|
||||
? Math.max(0, options.imageCacheBudgetBytes)
|
||||
: DEFAULT_IMAGE_BUDGET
|
||||
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
|
||||
? Math.max(0, options.audioCacheBudgetBytes)
|
||||
: DEFAULT_AUDIO_BUDGET
|
||||
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
|
||||
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
|
||||
: DEFAULT_AUDIO_MAX_ENTRIES
|
||||
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
|
||||
? Math.max(1, Math.floor(options.downloadConcurrency))
|
||||
: 2
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now
|
||||
const fs = assetPlatform
|
||||
&& typeof assetPlatform.getFileSystemManager === 'function'
|
||||
? assetPlatform.getFileSystemManager()
|
||||
: null
|
||||
const userDataPath = (
|
||||
assetPlatform
|
||||
&& assetPlatform.env
|
||||
&& assetPlatform.env.USER_DATA_PATH
|
||||
) || ''
|
||||
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
|
||||
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
|
||||
|
||||
let initialized = false
|
||||
let index = normalizeIndex(null)
|
||||
let activeDownloads = 0
|
||||
const inflight = new Map()
|
||||
const downloadWaiters = []
|
||||
|
||||
function fallback(assetId, reason) {
|
||||
return {
|
||||
assetId,
|
||||
available: false,
|
||||
uri: '',
|
||||
source: 'text-fallback',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCacheFolder() {
|
||||
if (!cacheRoot) return false
|
||||
try {
|
||||
await callFs(fs, 'mkdir', {
|
||||
dirPath: cacheRoot,
|
||||
recursive: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// EEXIST and older base-library mkdir failures are both safe to ignore.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function persistIndex() {
|
||||
if (!indexPath) return
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
await callFs(fs, 'writeFile', {
|
||||
filePath: indexPath,
|
||||
data: JSON.stringify(index),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
} catch (error) {
|
||||
// Cache metadata must never block the text-first game.
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
await callFs(fs, 'access', { path: filePath })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMatchesSha256(filePath, expectedSha256) {
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', { filePath })
|
||||
return constantTimeEqualHex(
|
||||
sha256Hex(result.data),
|
||||
expectedSha256,
|
||||
)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (!indexPath) return
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', {
|
||||
filePath: indexPath,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
index = normalizeIndex(JSON.parse(result.data))
|
||||
} catch (error) {
|
||||
index = normalizeIndex(null)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [assetId, entry] of Object.entries(index.entries)) {
|
||||
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await persistIndex()
|
||||
await enforceImageBudget()
|
||||
await enforceAudioBudget()
|
||||
}
|
||||
|
||||
function cacheBytes(kind) {
|
||||
return Object.values(index.entries).reduce(
|
||||
(total, entry) => (
|
||||
entry.kind === kind
|
||||
? total + Math.max(0, Number(entry.size) || 0)
|
||||
: total
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function cacheEntries(kind) {
|
||||
return Object.values(index.entries)
|
||||
.filter((entry) => entry.kind === kind)
|
||||
.length
|
||||
}
|
||||
|
||||
function imageCacheBytes() {
|
||||
return cacheBytes('image')
|
||||
}
|
||||
|
||||
function audioCacheBytes() {
|
||||
return cacheBytes('audio')
|
||||
}
|
||||
|
||||
async function unlinkQuietly(filePath) {
|
||||
try {
|
||||
await callFs(fs, 'unlink', { filePath })
|
||||
} catch (error) {
|
||||
// A missing cache file is already evicted.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceImageBudget(protectedAssetId = '') {
|
||||
let total = imageCacheBytes()
|
||||
if (total <= imageBudgetBytes) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'image' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= imageBudgetBytes) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function enforceAudioBudget(protectedAssetId = '') {
|
||||
let total = audioCacheBytes()
|
||||
let entries = cacheEntries('audio')
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'audio' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
entries -= 1
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function resolveCached(assetId, asset) {
|
||||
const entry = index.entries[assetId]
|
||||
if (!entry || entry.sha256 !== asset.sha256) return null
|
||||
if (!(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return null
|
||||
}
|
||||
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
|
||||
await unlinkQuietly(entry.filePath)
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
entry.lastAccessedAt = now()
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: entry.filePath,
|
||||
source: 'cache',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTempFileSize(tempFilePath, response) {
|
||||
if (Number.isFinite(response.fileSize)) return response.fileSize
|
||||
try {
|
||||
const statResult = await callFs(fs, 'stat', {
|
||||
path: tempFilePath,
|
||||
})
|
||||
const stat = statResult && statResult.stats
|
||||
return Math.max(0, Number(stat && stat.size) || 0)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(assetId, asset) {
|
||||
if (!cdnBaseUrl || !asset.remotePath) {
|
||||
return fallback(assetId, 'remote-disabled')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
activeDownloads += 1
|
||||
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1
|
||||
const next = downloadWaiters.shift()
|
||||
if (next) next()
|
||||
})
|
||||
}
|
||||
if (activeDownloads < downloadConcurrency) run()
|
||||
else downloadWaiters.push(run)
|
||||
})
|
||||
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
|
||||
await unlinkQuietly(response.tempFilePath)
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
const size = await getTempFileSize(response.tempFilePath, response)
|
||||
|
||||
const exceedsCachePolicy = (
|
||||
(asset.kind === 'image' && size > imageBudgetBytes)
|
||||
|| (
|
||||
asset.kind === 'audio'
|
||||
&& (size > audioBudgetBytes || audioMaxEntries < 1)
|
||||
)
|
||||
)
|
||||
if (!cacheRoot || exceedsCachePolicy) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
|
||||
await callFs(fs, 'saveFile', {
|
||||
tempFilePath: response.tempFilePath,
|
||||
filePath,
|
||||
})
|
||||
index.entries[assetId] = {
|
||||
assetId,
|
||||
filePath,
|
||||
kind: asset.kind,
|
||||
sha256: asset.sha256,
|
||||
size,
|
||||
lastAccessedAt: now(),
|
||||
}
|
||||
await enforceImageBudget(assetId)
|
||||
await enforceAudioBudget(assetId)
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: filePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(assetId, resolveOptions = {}) {
|
||||
await init()
|
||||
const asset = manifest[assetId]
|
||||
if (!asset) return fallback(assetId, 'unknown-asset')
|
||||
if (
|
||||
asset.kind === 'audio'
|
||||
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
|
||||
) {
|
||||
return fallback(assetId, 'audio-unapproved')
|
||||
}
|
||||
|
||||
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
|
||||
if (asset.localSeed && resolveOptions.preferRemote !== true) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
const cached = await resolveCached(assetId, asset)
|
||||
if (cached) return cached
|
||||
|
||||
if (resolveOptions.allowRemote === false) {
|
||||
if (asset.localSeed) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
return fallback(assetId, 'remote-disallowed')
|
||||
}
|
||||
|
||||
if (inflight.has(assetId)) return inflight.get(assetId)
|
||||
|
||||
const request = fetchRemote(assetId, asset)
|
||||
.catch(() => (
|
||||
asset.localSeed
|
||||
? {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
: fallback(assetId, 'remote-failed')
|
||||
))
|
||||
.finally(() => inflight.delete(assetId))
|
||||
inflight.set(assetId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function prefetch(assetIds) {
|
||||
const ids = Array.isArray(assetIds) ? assetIds : []
|
||||
return Promise.all(ids.map((assetId) => resolve(assetId)))
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await init()
|
||||
const entries = Object.values(index.entries)
|
||||
for (const entry of entries) await unlinkQuietly(entry.filePath)
|
||||
index = normalizeIndex(null)
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return {
|
||||
entries: Object.keys(index.entries).length,
|
||||
imageBytes: imageCacheBytes(),
|
||||
imageBudgetBytes,
|
||||
audioEntries: cacheEntries('audio'),
|
||||
audioBytes: audioCacheBytes(),
|
||||
audioBudgetBytes,
|
||||
audioMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
resolve,
|
||||
prefetch,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
getAsset(assetId) {
|
||||
return manifest[assetId] || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_INDEX_VERSION,
|
||||
DEFAULT_AUDIO_BUDGET,
|
||||
DEFAULT_AUDIO_MAX_ENTRIES,
|
||||
constantTimeEqualHex,
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
sha256Hex,
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
const REVIEWED_AUDIO_STATUS = 'approved'
|
||||
const REVIEWED_VISUAL_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_STATUS = 'experience-provisional'
|
||||
const PROVISIONAL_VISUAL_TIER = 'experience-provisional'
|
||||
const IMAGE_UNAVAILABLE_MESSAGE = '本页画面暂时没有打开,文字和互动仍可继续。'
|
||||
const AUDIO_FILE_PATTERN = /\.(?:aac|m4a|mp3|ogg|wav)$/i
|
||||
|
||||
function cleanPath(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isPlayableVisualDeclaration(visual) {
|
||||
if (!visual || typeof visual !== 'object') return false
|
||||
const reviewStatus = cleanPath(visual.reviewStatus).toLowerCase()
|
||||
if (reviewStatus === REVIEWED_VISUAL_STATUS) return true
|
||||
return Boolean(
|
||||
visual.kind === 'page-art'
|
||||
&& cleanPath(visual.runtimeTier) === PROVISIONAL_VISUAL_TIER
|
||||
&& visual.formalReleaseEligible === false
|
||||
&& reviewStatus === PROVISIONAL_VISUAL_STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
function isPackagedPath(value) {
|
||||
const path = cleanPath(value)
|
||||
return (
|
||||
path.startsWith('/assets/')
|
||||
|| /^\/package-[a-z0-9-]+\//i.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
function actorSheetFocusPercent(person) {
|
||||
const era = String(person && person.eraLabel || '')
|
||||
const characterId = String(person && person.characterId || '')
|
||||
if (characterId === 'qin-xiaoman') {
|
||||
if (/2008/.test(era)) return 16
|
||||
if (/2026/.test(era)) return 65
|
||||
}
|
||||
if (characterId === 'tang-mingyuan') {
|
||||
if (/1995/.test(era)) return 11
|
||||
if (/2001/.test(era)) return 37
|
||||
if (/2003/.test(era)) return 63
|
||||
if (/2026/.test(era)) return 88
|
||||
}
|
||||
if (/1978|1980/.test(era)) return 11
|
||||
if (/1993|1995|1998/.test(era)) return 31
|
||||
if (/2001|2003/.test(era)) return 50
|
||||
if (/2008/.test(era)) return 69
|
||||
if (/2026/.test(era)) return 88
|
||||
return 50
|
||||
}
|
||||
|
||||
function isSinglePortraitFallback(person) {
|
||||
return String(person && person.characterId || '') === 'female-cook'
|
||||
}
|
||||
|
||||
function getComicPageImageMode(person, usingActorFallback) {
|
||||
return usingActorFallback && isSinglePortraitFallback(person)
|
||||
? 'aspectFit'
|
||||
: 'scaleToFill'
|
||||
}
|
||||
|
||||
function getComicPageImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const focusX = actorSheetFocusPercent(person)
|
||||
const translateX = 50 - (3.5 * focusX)
|
||||
return [
|
||||
'transform-origin:0 0',
|
||||
`transform:translate(${translateX}%,-30%) scale(3.5)`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
function getComicFocusImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const sourceFocusX = actorSheetFocusPercent(person)
|
||||
const focusX = 25 + (sourceFocusX * 0.5)
|
||||
return [
|
||||
'transform:scale(7)',
|
||||
`transform-origin:${focusX}% 12%`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first image shown by a comic page.
|
||||
*
|
||||
* A release-manifest localSeed is authoritative and must never wait for the
|
||||
* network. A remote-only illustration starts on the reviewed scene fallback,
|
||||
* so a slow or failed download cannot leave a blank reader.
|
||||
*/
|
||||
function buildComicImageState(page = {}, releaseAsset = null, previous = {}) {
|
||||
const illustrationAsset = cleanPath(page.illustrationAsset)
|
||||
const fallbackAsset = cleanPath(page.fallbackAsset)
|
||||
const playableVisual = isPlayableVisualDeclaration(page.playableVisual)
|
||||
? page.playableVisual
|
||||
: null
|
||||
const sharedVisualKind = playableVisual
|
||||
&& playableVisual.kind !== 'page-art'
|
||||
? cleanPath(playableVisual.kind)
|
||||
: ''
|
||||
const sharedVisualAsset = sharedVisualKind
|
||||
? cleanPath(playableVisual.asset)
|
||||
: ''
|
||||
const actorFallbackAsset = cleanPath(
|
||||
(
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
? sharedVisualAsset
|
||||
: (page.actorFallbackAsset || page.actorPortrait),
|
||||
)
|
||||
const localSeed = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.localSeed)
|
||||
: ''
|
||||
const remotePath = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.remotePath)
|
||||
: ''
|
||||
const keepFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& fallbackAsset
|
||||
)
|
||||
const keepActorFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& previous.comicImageSource === 'actor-fallback'
|
||||
&& actorFallbackAsset
|
||||
)
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& (
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: sharedVisualAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'shared-portrait',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& sharedVisualKind === 'evidence-composite'
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'shared-evidence',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepActorFallback) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepFallback) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (localSeed) {
|
||||
return {
|
||||
src: localSeed,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'local-seed',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (remotePath) {
|
||||
const loadingFallback = actorFallbackAsset || fallbackAsset
|
||||
const usingActorFallback = Boolean(actorFallbackAsset)
|
||||
return {
|
||||
src: loadingFallback,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: Boolean(loadingFallback),
|
||||
usingActorFallback,
|
||||
source: usingActorFallback
|
||||
? 'actor-fallback-loading'
|
||||
: (fallbackAsset ? 'fallback-loading' : 'text-fallback-loading'),
|
||||
error: loadingFallback ? '' : IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseAsset && illustrationAsset) {
|
||||
return {
|
||||
src: illustrationAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'declared-local-path',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (actorFallbackAsset) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
src: '',
|
||||
fallback: '',
|
||||
actorFallback: '',
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'text-fallback',
|
||||
error: IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio is intentionally fail-closed. A non-empty src alone is not enough:
|
||||
* both the story data and immutable release manifest must mark the file as
|
||||
* approved, and the playable src must be the packaged, hash-checked seed.
|
||||
*/
|
||||
function getReviewedAudioSrc(media = {}, manifest = {}) {
|
||||
const status = cleanPath(media.status || media.audioStatus).toLowerCase()
|
||||
const assetId = cleanPath(media.assetId || media.audioAssetId)
|
||||
const declaredSrc = cleanPath(media.src || media.audioSrc)
|
||||
if (
|
||||
status !== REVIEWED_AUDIO_STATUS
|
||||
|| !assetId
|
||||
|| !declaredSrc
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const releaseAsset = manifest[assetId]
|
||||
if (
|
||||
!releaseAsset
|
||||
|| releaseAsset.kind !== 'audio'
|
||||
|| cleanPath(releaseAsset.reviewStatus).toLowerCase()
|
||||
!== REVIEWED_AUDIO_STATUS
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const localSeed = cleanPath(releaseAsset.localSeed)
|
||||
if (
|
||||
!localSeed
|
||||
|| localSeed !== declaredSrc
|
||||
|| !isPackagedPath(localSeed)
|
||||
|| !AUDIO_FILE_PATTERN.test(localSeed)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return localSeed
|
||||
}
|
||||
|
||||
function buildReviewedAudioState(media = {}, manifest = {}) {
|
||||
const src = getReviewedAudioSrc(media, manifest)
|
||||
return {
|
||||
available: Boolean(src),
|
||||
src,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDIO_FILE_PATTERN,
|
||||
IMAGE_UNAVAILABLE_MESSAGE,
|
||||
REVIEWED_AUDIO_STATUS,
|
||||
buildComicImageState,
|
||||
buildReviewedAudioState,
|
||||
getComicFocusImageStyle,
|
||||
getComicPageImageMode,
|
||||
getComicPageImageStyle,
|
||||
getReviewedAudioSrc,
|
||||
isPackagedPath,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
const COMIC_PAGE_TYPES = Object.freeze([
|
||||
'cover',
|
||||
'ensemble',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'emotion',
|
||||
'memory',
|
||||
])
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicPages(model = {}) {
|
||||
return Array.isArray(model.pageSequence) ? model.pageSequence : []
|
||||
}
|
||||
|
||||
function pageIndex(model, pageId) {
|
||||
const requested = cleanString(pageId)
|
||||
if (!requested) return -1
|
||||
return comicPages(model).findIndex((page) => page.pageId === requested)
|
||||
}
|
||||
|
||||
function eventPages(model) {
|
||||
return comicPages(model).filter((page) => page.type === 'event')
|
||||
}
|
||||
|
||||
function isEightPageComicModel(model = {}) {
|
||||
const pages = comicPages(model)
|
||||
if (
|
||||
model.mode !== 'comic'
|
||||
|| !cleanString(model.chapterId)
|
||||
|| pages.length !== COMIC_PAGE_TYPES.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return pages.every((page, index) => (
|
||||
page
|
||||
&& page.pageId === `${model.chapterId}-P${String(index + 1).padStart(2, '0')}`
|
||||
&& page.type === COMIC_PAGE_TYPES[index]
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a contiguous prefix of P03-P06 is trusted.
|
||||
*
|
||||
* This prevents stale, foreign, duplicated or out-of-order ids from opening a
|
||||
* later page. It also keeps the locked 60-event source order unchanged.
|
||||
*/
|
||||
function normalizeCompletedEventIds(model, completedIds = []) {
|
||||
const supplied = new Set(
|
||||
(Array.isArray(completedIds) ? completedIds : [])
|
||||
.map(cleanString)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const normalized = []
|
||||
for (const page of eventPages(model)) {
|
||||
if (!supplied.has(page.eventId)) break
|
||||
normalized.push(page.eventId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getUnlockedPageIndex(
|
||||
model,
|
||||
completedIds = [],
|
||||
chapterFinished = false,
|
||||
) {
|
||||
const pages = comicPages(model)
|
||||
if (!pages.length) return -1
|
||||
if (!isEightPageComicModel(model)) return 0
|
||||
if (chapterFinished) return pages.length - 1
|
||||
|
||||
const completed = normalizeCompletedEventIds(model, completedIds)
|
||||
const firstEventIndex = pages.findIndex((page) => page.type === 'event')
|
||||
const emotionIndex = pages.findIndex((page) => page.type === 'emotion')
|
||||
if (firstEventIndex < 0) return 0
|
||||
|
||||
return Math.min(
|
||||
emotionIndex >= 0 ? emotionIndex : pages.length - 1,
|
||||
firstEventIndex + completed.length,
|
||||
)
|
||||
}
|
||||
|
||||
function emptyReaderState() {
|
||||
return {
|
||||
valid: false,
|
||||
currentPage: null,
|
||||
currentPageId: '',
|
||||
currentPageIndex: -1,
|
||||
unlockedPageIndex: -1,
|
||||
unlockedPageIds: [],
|
||||
completedEventIds: [],
|
||||
allEventsComplete: false,
|
||||
chapterFinished: false,
|
||||
canGoPrevious: false,
|
||||
canGoNext: false,
|
||||
nextPageLocked: false,
|
||||
activeInteraction: null,
|
||||
activeHotspots: [],
|
||||
pageAccess: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildComicReaderState(model, progress = {}) {
|
||||
if (!isEightPageComicModel(model)) return emptyReaderState()
|
||||
|
||||
const pages = comicPages(model)
|
||||
const completedEventIds = normalizeCompletedEventIds(
|
||||
model,
|
||||
progress.completedEventIds || progress.completedIds,
|
||||
)
|
||||
const allEventsComplete = (
|
||||
completedEventIds.length === eventPages(model).length
|
||||
)
|
||||
const chapterFinished = Boolean(
|
||||
progress.chapterFinished && allEventsComplete,
|
||||
)
|
||||
const unlockedPageIndex = getUnlockedPageIndex(
|
||||
model,
|
||||
completedEventIds,
|
||||
chapterFinished,
|
||||
)
|
||||
const requestedPageId = (
|
||||
cleanString(progress.currentPageId)
|
||||
|| cleanString(progress.savedPageId)
|
||||
|| model.firstPageId
|
||||
|| pages[0].pageId
|
||||
)
|
||||
const requestedIndex = pageIndex(model, requestedPageId)
|
||||
const currentPageIndex = Math.min(
|
||||
unlockedPageIndex,
|
||||
Math.max(0, requestedIndex >= 0 ? requestedIndex : 0),
|
||||
)
|
||||
const currentPage = pages[currentPageIndex]
|
||||
const completed = new Set(completedEventIds)
|
||||
|
||||
let activeInteraction = null
|
||||
if (
|
||||
currentPage.type === 'event'
|
||||
&& !completed.has(currentPage.eventId)
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'event',
|
||||
id: currentPage.eventId,
|
||||
eventId: currentPage.eventId,
|
||||
pageId: currentPage.pageId,
|
||||
actorInstanceId: currentPage.actorInstanceId || '',
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
} else if (
|
||||
currentPage.type === 'emotion'
|
||||
&& allEventsComplete
|
||||
&& !chapterFinished
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'emotion',
|
||||
id: currentPage.emotionMomentId,
|
||||
emotionMomentId: currentPage.emotionMomentId,
|
||||
pageId: currentPage.pageId,
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
}
|
||||
|
||||
const activeHotspots = (
|
||||
activeInteraction
|
||||
&& activeInteraction.actorHotspot
|
||||
)
|
||||
? [{
|
||||
interactionId: activeInteraction.id,
|
||||
pageId: activeInteraction.pageId,
|
||||
actorHotspot: activeInteraction.actorHotspot,
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
currentPage,
|
||||
currentPageId: currentPage.pageId,
|
||||
currentPageIndex,
|
||||
unlockedPageIndex,
|
||||
unlockedPageIds: pages
|
||||
.slice(0, unlockedPageIndex + 1)
|
||||
.map((page) => page.pageId),
|
||||
completedEventIds,
|
||||
allEventsComplete,
|
||||
chapterFinished,
|
||||
canGoPrevious: currentPageIndex > 0,
|
||||
canGoNext: currentPageIndex < unlockedPageIndex,
|
||||
nextPageLocked: (
|
||||
currentPageIndex < pages.length - 1
|
||||
&& currentPageIndex >= unlockedPageIndex
|
||||
),
|
||||
activeInteraction,
|
||||
activeHotspots,
|
||||
pageAccess: pages.map((page, index) => ({
|
||||
pageId: page.pageId,
|
||||
pageIndex: index,
|
||||
type: page.type,
|
||||
unlocked: index <= unlockedPageIndex,
|
||||
current: index === currentPageIndex,
|
||||
interactionEnabled: Boolean(
|
||||
activeInteraction
|
||||
&& activeInteraction.pageId === page.pageId
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function progressFromState(state) {
|
||||
return {
|
||||
currentPageId: state.currentPageId,
|
||||
completedEventIds: [...state.completedEventIds],
|
||||
chapterFinished: state.chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one chapter from the shared v1 storage object.
|
||||
*
|
||||
* `comicReaderByChapter` is the canonical reader cursor. The older
|
||||
* completedHotspots/completedChapters/lastPageId fields remain mirrored for
|
||||
* catalog compatibility and safe migration from already-installed builds.
|
||||
*/
|
||||
function readComicReaderProgress(storageProgress = {}, model = {}, chapterNumber) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
const storedByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? storageProgress.comicReaderByChapter
|
||||
: {}
|
||||
const storedChapter = (
|
||||
chapterId
|
||||
&& storedByChapter[chapterId]
|
||||
&& typeof storedByChapter[chapterId] === 'object'
|
||||
)
|
||||
? storedByChapter[chapterId]
|
||||
: {}
|
||||
const legacyHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
&& Array.isArray(storageProgress.completedHotspots[chapterId])
|
||||
)
|
||||
? storageProgress.completedHotspots[chapterId]
|
||||
: []
|
||||
const legacyFinished = (
|
||||
Array.isArray(storageProgress.completedChapters)
|
||||
&& storageProgress.completedChapters.includes(chapterId)
|
||||
)
|
||||
const chapterFinished = (
|
||||
typeof storedChapter.chapterFinished === 'boolean'
|
||||
? storedChapter.chapterFinished
|
||||
: legacyFinished
|
||||
)
|
||||
const isLastChapter = (
|
||||
Number(storageProgress.lastChapter) === Number(chapterNumber)
|
||||
)
|
||||
|
||||
return {
|
||||
currentPageId: (
|
||||
cleanString(storedChapter.currentPageId)
|
||||
|| (isLastChapter ? cleanString(storageProgress.lastPageId) : '')
|
||||
|| (chapterFinished ? cleanString(model.lastPageId) : '')
|
||||
|| cleanString(model.firstPageId)
|
||||
),
|
||||
completedEventIds: Array.isArray(storedChapter.completedEventIds)
|
||||
? [...storedChapter.completedEventIds]
|
||||
: [...legacyHotspots],
|
||||
chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge only the current chapter back into shared storage.
|
||||
*
|
||||
* Other chapters and unrelated product fields are copied through untouched.
|
||||
* The supplied reader progress is normalized again here so stale `finished`
|
||||
* flags and out-of-order future event ids cannot be persisted.
|
||||
*/
|
||||
function mergeComicReaderProgress(
|
||||
storageProgress = {},
|
||||
model = {},
|
||||
chapterNumber,
|
||||
readerProgress = {},
|
||||
) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
if (!chapterId || !isEightPageComicModel(model)) {
|
||||
return { ...storageProgress }
|
||||
}
|
||||
const state = buildComicReaderState(model, readerProgress)
|
||||
const normalized = progressFromState(state)
|
||||
const completedHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
)
|
||||
? { ...storageProgress.completedHotspots }
|
||||
: {}
|
||||
const comicReaderByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? { ...storageProgress.comicReaderByChapter }
|
||||
: {}
|
||||
const completedChapters = Array.isArray(storageProgress.completedChapters)
|
||||
? storageProgress.completedChapters.filter(
|
||||
(storedChapterId) => storedChapterId !== chapterId,
|
||||
)
|
||||
: []
|
||||
|
||||
completedHotspots[chapterId] = [...normalized.completedEventIds]
|
||||
comicReaderByChapter[chapterId] = normalized
|
||||
if (normalized.chapterFinished) completedChapters.push(chapterId)
|
||||
|
||||
return {
|
||||
...storageProgress,
|
||||
completedHotspots,
|
||||
completedChapters,
|
||||
comicReaderByChapter,
|
||||
lastChapter: Number(chapterNumber) || model.chapterNumber || 1,
|
||||
lastPageId: normalized.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transition helper. Completing an interaction unlocks the following
|
||||
* page but never turns it automatically, so the reader still controls the
|
||||
* lianhuanhua rhythm.
|
||||
*/
|
||||
function applyComicReaderAction(model, progress = {}, action = {}) {
|
||||
const state = buildComicReaderState(model, progress)
|
||||
if (!state.valid) return progressFromState(state)
|
||||
const type = cleanString(action.type)
|
||||
let nextProgress = progressFromState(state)
|
||||
|
||||
if (type === 'previous-page' && state.canGoPrevious) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex - 1
|
||||
].pageId
|
||||
} else if (type === 'next-page' && state.canGoNext) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex + 1
|
||||
].pageId
|
||||
} else if (type === 'open-page') {
|
||||
const targetIndex = pageIndex(model, action.pageId)
|
||||
if (targetIndex >= 0 && targetIndex <= state.unlockedPageIndex) {
|
||||
nextProgress.currentPageId = comicPages(model)[targetIndex].pageId
|
||||
}
|
||||
} else if (
|
||||
type === 'complete-event'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'event'
|
||||
&& state.activeInteraction.eventId === cleanString(action.eventId)
|
||||
) {
|
||||
nextProgress.completedEventIds = [
|
||||
...state.completedEventIds,
|
||||
state.activeInteraction.eventId,
|
||||
]
|
||||
} else if (
|
||||
type === 'complete-emotion'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'emotion'
|
||||
&& state.activeInteraction.emotionMomentId
|
||||
=== cleanString(action.emotionMomentId)
|
||||
) {
|
||||
nextProgress.chapterFinished = true
|
||||
}
|
||||
|
||||
return progressFromState(buildComicReaderState(model, nextProgress))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared illustration path is not proof that a finished page exists.
|
||||
* Callers must explicitly confirm formalAvailable after package/manifest
|
||||
* validation. Otherwise the state is visibly a fallback, never "formal art".
|
||||
*/
|
||||
function buildComicArtAvailability(page = {}, options = {}) {
|
||||
const formalAsset = cleanString(
|
||||
options.formalAsset || page.illustrationAsset,
|
||||
)
|
||||
const fallbackAsset = cleanString(
|
||||
options.fallbackAsset || page.fallbackAsset,
|
||||
)
|
||||
if (options.formalAvailable === true && formalAsset) {
|
||||
return {
|
||||
mode: 'formal-art',
|
||||
src: formalAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: true,
|
||||
isFallback: false,
|
||||
}
|
||||
}
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
mode: 'scene-fallback',
|
||||
src: fallbackAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'text-only',
|
||||
src: '',
|
||||
formalAsset,
|
||||
fallbackAsset: '',
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_PAGE_TYPES,
|
||||
applyComicReaderAction,
|
||||
buildComicArtAvailability,
|
||||
buildComicReaderState,
|
||||
getUnlockedPageIndex,
|
||||
isEightPageComicModel,
|
||||
mergeComicReaderProgress,
|
||||
normalizeCompletedEventIds,
|
||||
pageIndex,
|
||||
readComicReaderProgress,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 发布环境可覆盖这些值。
|
||||
*
|
||||
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
|
||||
* 使用包内种子图或返回文字回退,不会发起网络请求。
|
||||
*/
|
||||
module.exports = {
|
||||
cdnBaseUrl: '',
|
||||
imageCacheBudgetBytes: 28 * 1024 * 1024,
|
||||
audioCacheBudgetBytes: 32 * 1024 * 1024,
|
||||
audioCacheMaxEntries: 32,
|
||||
prefetchAhead: 2,
|
||||
downloadConcurrency: 2,
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
const { releaseAssets } = require('./releaseAssetManifest')
|
||||
|
||||
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
|
||||
const RUNTIME_VISUAL_TIERS = Object.freeze({
|
||||
FORMAL: 'formal',
|
||||
PROVISIONAL: 'experience-provisional',
|
||||
FALLBACK: 'fallback',
|
||||
})
|
||||
|
||||
// This set deliberately mirrors `usable` in
|
||||
// docs/production/art-release-gate-s01.json. Registered page art outside this
|
||||
// set may be shown only as experience-provisional and never counts as formal.
|
||||
const FORMAL_PAGE_ART_IDS = new Set([
|
||||
'S01-C01-P01',
|
||||
'S01-C01-P02',
|
||||
'S01-C01-P03',
|
||||
'S01-C01-P04',
|
||||
'S01-C01-P05',
|
||||
'S01-C01-P07',
|
||||
'S01-C01-P08',
|
||||
'S01-C02-P01',
|
||||
'S01-C02-P02',
|
||||
'S01-C02-P03',
|
||||
'S01-C02-P04',
|
||||
'S01-C02-P05',
|
||||
'S01-C02-P06',
|
||||
'S01-C02-P07',
|
||||
'S01-C02-P08',
|
||||
'S01-C03-P01',
|
||||
'S01-C03-P02',
|
||||
'S01-C03-P03',
|
||||
'S01-C03-P04',
|
||||
'S01-C03-P05',
|
||||
'S01-C03-P07',
|
||||
'S01-C03-P08',
|
||||
'S01-C04-P01',
|
||||
'S01-C04-P02',
|
||||
'S01-C04-P03',
|
||||
'S01-C04-P05',
|
||||
'S01-C04-P06',
|
||||
'S01-C04-P07',
|
||||
'S01-C04-P08',
|
||||
'S01-C05-P01',
|
||||
'S01-C05-P02',
|
||||
'S01-C05-P03',
|
||||
'S01-C05-P04',
|
||||
'S01-C05-P05',
|
||||
'S01-C05-P06',
|
||||
'S01-C05-P07',
|
||||
'S01-C05-P08',
|
||||
'S01-C06-P01',
|
||||
'S01-C06-P02',
|
||||
'S01-C06-P03',
|
||||
'S01-C06-P04',
|
||||
'S01-C06-P05',
|
||||
'S01-C06-P06',
|
||||
'S01-C06-P07',
|
||||
'S01-C06-P08',
|
||||
'S01-C07-P01',
|
||||
'S01-C07-P02',
|
||||
'S01-C07-P03',
|
||||
'S01-C07-P04',
|
||||
'S01-C07-P05',
|
||||
'S01-C07-P06',
|
||||
'S01-C07-P07',
|
||||
'S01-C07-P08',
|
||||
'S01-C08-P02',
|
||||
'S01-C08-P03',
|
||||
'S01-C08-P04',
|
||||
'S01-C08-P05',
|
||||
'S01-C08-P06',
|
||||
'S01-C08-P07',
|
||||
'S01-C08-P08',
|
||||
'S01-C09-P02',
|
||||
'S01-C09-P03',
|
||||
'S01-C09-P04',
|
||||
'S01-C09-P07',
|
||||
'S01-C09-P08',
|
||||
'S01-C10-P01',
|
||||
'S01-C10-P02',
|
||||
'S01-C10-P03',
|
||||
'S01-C10-P04',
|
||||
'S01-C10-P05',
|
||||
'S01-C10-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C10-P08',
|
||||
'S01-C11-P01',
|
||||
'S01-C11-P02',
|
||||
'S01-C11-P03',
|
||||
'S01-C11-P04',
|
||||
'S01-C11-P05',
|
||||
'S01-C11-P06',
|
||||
'S01-C11-P07',
|
||||
'S01-C11-P08',
|
||||
'S01-C12-P01',
|
||||
'S01-C12-P02',
|
||||
'S01-C12-P03',
|
||||
'S01-C12-P04',
|
||||
'S01-C12-P05',
|
||||
'S01-C12-P06',
|
||||
'S01-C12-P07',
|
||||
'S01-C12-P08',
|
||||
'S01-C13-P01',
|
||||
'S01-C13-P02',
|
||||
'S01-C13-P03',
|
||||
'S01-C13-P05',
|
||||
'S01-C13-P06',
|
||||
'S01-C13-P07',
|
||||
'S01-C13-P08',
|
||||
'S01-C14-P01',
|
||||
'S01-C14-P02',
|
||||
'S01-C14-P03',
|
||||
'S01-C14-P04',
|
||||
'S01-C14-P05',
|
||||
'S01-C14-P06',
|
||||
'S01-C14-P07',
|
||||
'S01-C14-P08',
|
||||
'S01-C15-P01',
|
||||
'S01-C15-P02',
|
||||
'S01-C15-P03',
|
||||
'S01-C15-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P06',
|
||||
'S01-C15-P07',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicAssetIdForPageId(pageId) {
|
||||
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
|
||||
if (!match) return ''
|
||||
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
|
||||
}
|
||||
|
||||
function registeredPageArtFor(page) {
|
||||
const assetId = comicAssetIdForPageId(page && page.pageId)
|
||||
const releaseAsset = assetId ? releaseAssets[assetId] : null
|
||||
return Boolean(
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
&& clean(releaseAsset.localSeed)
|
||||
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
|
||||
)
|
||||
}
|
||||
|
||||
function createPageArtVisual(page, chapter, runtimeTier) {
|
||||
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
|
||||
return {
|
||||
kind: 'page-art',
|
||||
asset: clean(page.illustrationAsset),
|
||||
subject: clean(page.actorName || page.headline || chapter.title),
|
||||
runtimeTier,
|
||||
formalReleaseEligible: formal,
|
||||
reviewStatus: formal
|
||||
? PLAYABLE_VISUAL_REVIEW_STATUS
|
||||
: PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: formal
|
||||
? 'art-release-gate-s01-usable'
|
||||
: 'registered-immutable-asset-experience-only',
|
||||
}
|
||||
}
|
||||
|
||||
function personByInstanceId(chapter, instanceId) {
|
||||
return (chapter.people || []).find(
|
||||
(person) => (
|
||||
person.instanceId === instanceId
|
||||
&& clean(person.portrait)
|
||||
),
|
||||
) || null
|
||||
}
|
||||
|
||||
function personByName(chapter, name) {
|
||||
const cleanName = clean(name)
|
||||
if (!cleanName) return null
|
||||
return (chapter.people || []).find(
|
||||
(person) => person.name === cleanName && clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function featuredPersonForPage(page, chapter) {
|
||||
const direct = personByInstanceId(chapter, page.actorInstanceId)
|
||||
if (direct) return direct
|
||||
|
||||
if (page.type === 'memory') {
|
||||
const memoryPerson = personByName(
|
||||
chapter,
|
||||
page.memoryCard && page.memoryCard.characterName,
|
||||
)
|
||||
if (memoryPerson) return memoryPerson
|
||||
}
|
||||
|
||||
if (page.type === 'emotion') {
|
||||
const emotionPerson = personByName(
|
||||
chapter,
|
||||
chapter.emotionMoment
|
||||
&& chapter.emotionMoment.character
|
||||
&& chapter.emotionMoment.character.name,
|
||||
)
|
||||
if (emotionPerson) return emotionPerson
|
||||
}
|
||||
|
||||
return (chapter.people || []).find(
|
||||
(person) => clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function createPlayableVisual(page, chapter) {
|
||||
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
|
||||
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
|
||||
}
|
||||
|
||||
if (registeredPageArtFor(page)) {
|
||||
return createPageArtVisual(
|
||||
page,
|
||||
chapter,
|
||||
RUNTIME_VISUAL_TIERS.PROVISIONAL,
|
||||
)
|
||||
}
|
||||
|
||||
if (page.type === 'event') {
|
||||
const portrait = clean(page.actorPortrait)
|
||||
if (portrait) {
|
||||
return {
|
||||
kind: 'actor-portrait',
|
||||
asset: portrait,
|
||||
subject: clean(page.actorName),
|
||||
actorInstanceId: clean(page.actorInstanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.actorName),
|
||||
evidenceLabel: clean(page.headline || '看看手边证据'),
|
||||
evidenceDetail: clean(
|
||||
page.secondaryCaption || page.caption || page.question,
|
||||
),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPerson = featuredPersonForPage(page, chapter)
|
||||
if (featuredPerson) {
|
||||
return {
|
||||
kind: 'chapter-character',
|
||||
asset: clean(featuredPerson.portrait),
|
||||
subject: clean(featuredPerson.name),
|
||||
actorInstanceId: clean(featuredPerson.instanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive last resort. The season currently never reaches this branch,
|
||||
// but if a future chapter lacks a portrait it still gets explicit evidence
|
||||
// instead of an unexplained empty room.
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.headline || chapter.title),
|
||||
evidenceLabel: clean(page.headline || chapter.title),
|
||||
evidenceDetail: clean(page.caption || chapter.narration),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
function attachPlayableVisuals(pageSequence, chapter) {
|
||||
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
|
||||
...page,
|
||||
playableVisual: createPlayableVisual(page, chapter),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORMAL_PAGE_ART_IDS,
|
||||
PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
RUNTIME_VISUAL_TIERS,
|
||||
attachPlayableVisuals,
|
||||
createPlayableVisual,
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Generated package-local production comic data. Do not hand-edit.
|
||||
module.exports = {
|
||||
"S01-C06": {
|
||||
"pages": [
|
||||
{
|
||||
"pageId": "S01-C06-P01",
|
||||
"type": "chapter-title",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C06-P01-title-v1.jpg",
|
||||
"caption": "旧钟走到一九九五,木牌翻面,两种吃饭办法在门槛碰上。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "翻开这一回",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C06-MT000",
|
||||
"S01-C06-MS001"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C06-P02",
|
||||
"type": "ensemble",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C06-P02-sign-flip-ensemble-v1.jpg",
|
||||
"caption": "老工友递饭票,新客递现金;秦师傅两手都接住,林秀兰先把员工饭扣好。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "先看看,谁在适应新办法,谁还在守住旧承诺",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C06-MS002",
|
||||
"S01-C06-MS003",
|
||||
"S01-C06-MS004"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C06-P03",
|
||||
"type": "event",
|
||||
"eventId": "S01-H21",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C06-P03-H21-contract-boundary-v1.jpg",
|
||||
"caption": "秦师傅签下承包责任书,又按住桌凳清单;林秀兰提醒,经营和家产不是一回事。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "说秦师傅签字后,厂房和食堂就全归个人所有,这样妥当吗?",
|
||||
"shortDialogue": "你承的是经营,不是把厂房揣进围裙兜里。",
|
||||
"hotspot": {
|
||||
"x": 24,
|
||||
"y": 0,
|
||||
"w": 55,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C06-MS002",
|
||||
"S01-C06-MS003"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C06-P04",
|
||||
"type": "event",
|
||||
"eventId": "S01-H22",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C06-P04-H22-ticket-cash-hands-v1.jpg",
|
||||
"caption": "老工友的饭票停在半空,笑声刚起,林秀兰已经走来:“别急,我给您说清。”",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "制度变了就嘲笑仍递饭票的老工友,这样妥当吗?",
|
||||
"shortDialogue": "这票不能用了?我昨儿还拿它吃过午饭。",
|
||||
"hotspot": {
|
||||
"x": 17,
|
||||
"y": 0,
|
||||
"w": 54,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C06-MS004",
|
||||
"S01-C06-MS005",
|
||||
"S01-C06-MS006"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C06-P05",
|
||||
"type": "event",
|
||||
"eventId": "S01-H23",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C06-P05-H23-covered-staff-meal-v1.jpg",
|
||||
"caption": "秦师傅把“欢迎光临”练了三遍,开口还是“下一位”;林秀兰却先把员工吃饭的班次排好。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "开业再忙也先安排员工轮流坐下吃饭,这样更稳妥吗?",
|
||||
"shortDialogue": "先把里头的人喂上,再学当老板。轮到谁吃,纸上写清。",
|
||||
"hotspot": {
|
||||
"x": 23,
|
||||
"y": 0,
|
||||
"w": 57,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C06-MS007",
|
||||
"S01-C06-MS008",
|
||||
"S01-C06-MS009",
|
||||
"S01-C06-MS010"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C06-P06",
|
||||
"type": "event",
|
||||
"eventId": "S01-H24",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C06-P06-H24-protect-table-v1.jpg",
|
||||
"caption": "客人伸手指向第十五桌,秦师傅挡在桌前,另一手却碰了一下刚收钱的铁盒。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "对外营业后仍为员工保留一张能真正坐下吃饭的桌,这样更稳妥吗?",
|
||||
"shortDialogue": "这桌给当班的人吃饭。我给您并旁边两桌,席面一样坐得开。",
|
||||
"hotspot": {
|
||||
"x": 32,
|
||||
"y": 0,
|
||||
"w": 56,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C06-MS011",
|
||||
"S01-C06-MS012"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C06-P07",
|
||||
"type": "emotion",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "S01-EM06",
|
||||
"assetName": "S01-C06-P07-EM06-keep-seat-v1.jpg",
|
||||
"caption": "木牌翻了面,老工友仍能坐下,员工饭也没有被忙乱忘在碗盖下面。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "饭馆要往前走,第十五桌怎样继续留下来?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": {
|
||||
"x": 21,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C06-TE900"
|
||||
],
|
||||
"tableEcho": "门牌可以翻面,留给人的座位不能翻没。",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C06-P08",
|
||||
"type": "memory-card-and-cliffhanger",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C06-P08-table-held-cliffhanger-v1.jpg",
|
||||
"caption": "门牌可以翻面,留给人的座位不能翻没。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "收进我的桂香岁月",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C06-MS013"
|
||||
],
|
||||
"tableEcho": "门牌可以翻面,留给人的座位不能翻没。",
|
||||
"cliffhanger": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* 发布固定资产清单 v1
|
||||
*
|
||||
* 约束:
|
||||
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
|
||||
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
|
||||
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
|
||||
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
|
||||
*/
|
||||
|
||||
const RELEASE_ASSET_MANIFEST_VERSION = 1
|
||||
|
||||
const releaseAssets = {
|
||||
'comic.s01.c01.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
|
||||
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
|
||||
},
|
||||
'comic.s01.c01.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
|
||||
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
|
||||
},
|
||||
'comic.s01.c01.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
|
||||
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
|
||||
},
|
||||
'comic.s01.c01.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
|
||||
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
|
||||
},
|
||||
'comic.s01.c01.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
|
||||
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
|
||||
},
|
||||
'comic.s01.c01.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
|
||||
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
|
||||
},
|
||||
'comic.s01.c01.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
|
||||
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
|
||||
},
|
||||
'comic.s01.c01.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
|
||||
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
|
||||
},
|
||||
'comic.s01.c02.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
|
||||
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
|
||||
},
|
||||
'comic.s01.c02.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
|
||||
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
|
||||
},
|
||||
'comic.s01.c02.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
|
||||
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
|
||||
},
|
||||
'comic.s01.c02.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
|
||||
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
|
||||
},
|
||||
'comic.s01.c02.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
|
||||
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
|
||||
},
|
||||
'comic.s01.c02.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
|
||||
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
|
||||
},
|
||||
'comic.s01.c02.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
|
||||
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
|
||||
},
|
||||
'comic.s01.c02.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
|
||||
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
|
||||
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
|
||||
},
|
||||
'comic.s01.c03.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
|
||||
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
|
||||
},
|
||||
'comic.s01.c03.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
|
||||
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
|
||||
},
|
||||
'comic.s01.c03.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
|
||||
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
|
||||
},
|
||||
'comic.s01.c03.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
|
||||
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
|
||||
},
|
||||
'comic.s01.c03.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
|
||||
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
|
||||
},
|
||||
'comic.s01.c03.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
|
||||
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
|
||||
},
|
||||
'comic.s01.c03.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
|
||||
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
|
||||
},
|
||||
'comic.s01.c03.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
|
||||
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
|
||||
},
|
||||
'comic.s01.c04.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
|
||||
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
|
||||
},
|
||||
'comic.s01.c04.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
|
||||
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
|
||||
},
|
||||
'comic.s01.c04.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
|
||||
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
|
||||
},
|
||||
'comic.s01.c04.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
|
||||
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
|
||||
},
|
||||
'comic.s01.c04.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
|
||||
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
|
||||
},
|
||||
'comic.s01.c04.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
|
||||
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
|
||||
},
|
||||
'comic.s01.c04.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
|
||||
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
|
||||
},
|
||||
'comic.s01.c04.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
|
||||
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
|
||||
},
|
||||
'comic.s01.c05.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
|
||||
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
|
||||
},
|
||||
'comic.s01.c05.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
|
||||
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
|
||||
},
|
||||
'comic.s01.c05.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
|
||||
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
|
||||
},
|
||||
'comic.s01.c05.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
|
||||
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
|
||||
},
|
||||
'comic.s01.c05.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
|
||||
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
|
||||
},
|
||||
'comic.s01.c05.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
|
||||
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
|
||||
},
|
||||
'comic.s01.c05.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
|
||||
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
|
||||
},
|
||||
'comic.s01.c05.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
|
||||
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
|
||||
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
|
||||
},
|
||||
'comic.s01.c06.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
|
||||
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
|
||||
},
|
||||
'comic.s01.c06.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
|
||||
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
|
||||
},
|
||||
'comic.s01.c06.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
|
||||
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
|
||||
},
|
||||
'comic.s01.c06.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
|
||||
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
|
||||
},
|
||||
'comic.s01.c06.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
|
||||
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
|
||||
},
|
||||
'comic.s01.c06.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
|
||||
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
|
||||
},
|
||||
'comic.s01.c06.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
|
||||
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
|
||||
},
|
||||
'comic.s01.c06.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
|
||||
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
|
||||
},
|
||||
'comic.s01.c07.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
|
||||
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
|
||||
},
|
||||
'comic.s01.c07.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
|
||||
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
|
||||
},
|
||||
'comic.s01.c07.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
|
||||
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
|
||||
},
|
||||
'comic.s01.c07.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
|
||||
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
|
||||
},
|
||||
'comic.s01.c07.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
|
||||
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
|
||||
},
|
||||
'comic.s01.c07.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
|
||||
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
|
||||
},
|
||||
'comic.s01.c07.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
|
||||
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
|
||||
},
|
||||
'comic.s01.c07.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
|
||||
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
|
||||
},
|
||||
'comic.s01.c08.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
|
||||
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
|
||||
},
|
||||
'comic.s01.c08.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
|
||||
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
|
||||
},
|
||||
'comic.s01.c08.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
|
||||
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
|
||||
},
|
||||
'comic.s01.c08.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
|
||||
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
|
||||
},
|
||||
'comic.s01.c08.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
|
||||
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
|
||||
},
|
||||
'comic.s01.c08.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
|
||||
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
|
||||
},
|
||||
'comic.s01.c08.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
|
||||
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
|
||||
},
|
||||
'comic.s01.c08.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
|
||||
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
|
||||
},
|
||||
'comic.s01.c09.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
|
||||
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
|
||||
},
|
||||
'comic.s01.c09.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
|
||||
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
|
||||
},
|
||||
'comic.s01.c09.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
|
||||
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
|
||||
},
|
||||
'comic.s01.c09.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
|
||||
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
|
||||
},
|
||||
'comic.s01.c09.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
|
||||
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
|
||||
},
|
||||
'comic.s01.c09.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
|
||||
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
|
||||
},
|
||||
'comic.s01.c09.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
|
||||
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
|
||||
},
|
||||
'comic.s01.c09.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
|
||||
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
|
||||
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
|
||||
},
|
||||
'comic.s01.c10.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
|
||||
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
|
||||
},
|
||||
'comic.s01.c10.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
|
||||
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
|
||||
},
|
||||
'comic.s01.c10.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
|
||||
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
|
||||
},
|
||||
'comic.s01.c10.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
|
||||
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
|
||||
},
|
||||
'comic.s01.c10.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
|
||||
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
|
||||
},
|
||||
'comic.s01.c10.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
|
||||
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
|
||||
},
|
||||
'comic.s01.c10.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
|
||||
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
|
||||
},
|
||||
'comic.s01.c10.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
|
||||
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
|
||||
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
|
||||
},
|
||||
'comic.s01.c11.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
|
||||
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
|
||||
},
|
||||
'comic.s01.c11.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
|
||||
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
|
||||
},
|
||||
'comic.s01.c11.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
|
||||
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
|
||||
},
|
||||
'comic.s01.c11.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
|
||||
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
|
||||
},
|
||||
'comic.s01.c11.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
|
||||
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
|
||||
},
|
||||
'comic.s01.c11.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
|
||||
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
|
||||
},
|
||||
'comic.s01.c11.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
|
||||
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
|
||||
},
|
||||
'comic.s01.c11.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
|
||||
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
|
||||
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
|
||||
},
|
||||
'comic.s01.c12.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
|
||||
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
|
||||
},
|
||||
'comic.s01.c12.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
|
||||
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
|
||||
},
|
||||
'comic.s01.c12.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
|
||||
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
|
||||
},
|
||||
'comic.s01.c12.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
|
||||
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
|
||||
},
|
||||
'comic.s01.c12.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
|
||||
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
|
||||
},
|
||||
'comic.s01.c12.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
|
||||
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
|
||||
},
|
||||
'comic.s01.c12.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
|
||||
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
|
||||
},
|
||||
'comic.s01.c12.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
|
||||
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
|
||||
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
|
||||
},
|
||||
'comic.s01.c13.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
|
||||
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
|
||||
},
|
||||
'comic.s01.c13.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
|
||||
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
|
||||
},
|
||||
'comic.s01.c13.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
|
||||
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
|
||||
},
|
||||
'comic.s01.c13.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
|
||||
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
|
||||
},
|
||||
'comic.s01.c13.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
|
||||
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
|
||||
},
|
||||
'comic.s01.c13.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
|
||||
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
|
||||
},
|
||||
'comic.s01.c13.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
|
||||
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
|
||||
},
|
||||
'comic.s01.c13.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
|
||||
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
|
||||
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
|
||||
},
|
||||
'comic.s01.c14.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
|
||||
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
|
||||
},
|
||||
'comic.s01.c14.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
|
||||
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
|
||||
},
|
||||
'comic.s01.c14.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
|
||||
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
|
||||
},
|
||||
'comic.s01.c14.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
|
||||
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
|
||||
},
|
||||
'comic.s01.c14.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
|
||||
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
|
||||
},
|
||||
'comic.s01.c14.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
|
||||
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
|
||||
},
|
||||
'comic.s01.c14.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
|
||||
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
|
||||
},
|
||||
'comic.s01.c14.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
|
||||
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
|
||||
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
|
||||
},
|
||||
'comic.s01.c15.p01': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
|
||||
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
|
||||
},
|
||||
'comic.s01.c15.p02': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
|
||||
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
|
||||
},
|
||||
'comic.s01.c15.p03': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
|
||||
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
|
||||
},
|
||||
'comic.s01.c15.p04': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
|
||||
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
|
||||
},
|
||||
'comic.s01.c15.p05': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
|
||||
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
|
||||
},
|
||||
'comic.s01.c15.p06': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
|
||||
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
|
||||
},
|
||||
'comic.s01.c15.p07': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
|
||||
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
|
||||
},
|
||||
'comic.s01.c15.p08': {
|
||||
kind: 'image',
|
||||
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
|
||||
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
|
||||
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
|
||||
},
|
||||
'character.female-cook': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/female-cook.jpg',
|
||||
remotePath: 'characters/female-cook.c56420b2c632.jpg',
|
||||
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
|
||||
},
|
||||
'character.lele': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lele.jpg',
|
||||
remotePath: 'characters/lele.807f5232b1e8.jpg',
|
||||
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
|
||||
},
|
||||
'character.lin-xiulan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/lin-xiulan.jpg',
|
||||
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
|
||||
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
|
||||
},
|
||||
'character.qin-xiaoman': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-xiaoman.jpg',
|
||||
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
|
||||
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
|
||||
},
|
||||
'character.qin-zhicheng': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/qin-zhicheng.jpg',
|
||||
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
|
||||
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
|
||||
},
|
||||
'character.tang-mingyuan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-mingyuan.jpg',
|
||||
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
|
||||
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
|
||||
},
|
||||
'character.tang-shouan': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/tang-shouan.jpg',
|
||||
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
|
||||
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
|
||||
},
|
||||
'character.xiaozhen': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/xiaozhen.jpg',
|
||||
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
|
||||
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
|
||||
},
|
||||
'character.zhao-jianguo': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/characters/zhao-jianguo.jpg',
|
||||
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
|
||||
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
|
||||
},
|
||||
'scene.gui-xiang.1978': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
|
||||
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
|
||||
},
|
||||
'scene.gui-xiang.1995': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
|
||||
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
|
||||
},
|
||||
'scene.gui-xiang.1998': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
|
||||
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
|
||||
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
|
||||
},
|
||||
'scene.gui-xiang.2001': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
|
||||
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
|
||||
},
|
||||
'scene.gui-xiang.2003': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
|
||||
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
|
||||
},
|
||||
'scene.gui-xiang.2008': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
|
||||
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
|
||||
},
|
||||
'scene.gui-xiang.2026': {
|
||||
kind: 'image',
|
||||
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
|
||||
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
|
||||
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
|
||||
},
|
||||
'audio.cast-audition.v1': {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'audition',
|
||||
localSeed: '',
|
||||
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
|
||||
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
|
||||
// The reader no longer maps them as page narration; C01 uses full-page tracks
|
||||
// through the two package-local audio player pages instead.
|
||||
for (const cueId of [
|
||||
'S01-C01-MS007','S01-C01-MS010',
|
||||
]) {
|
||||
const file = `${cueId}.mp3`
|
||||
const sha256 = {
|
||||
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
|
||||
}[cueId]
|
||||
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
|
||||
}
|
||||
|
||||
const audioC02Sha256 = {
|
||||
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
|
||||
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
|
||||
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
|
||||
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
|
||||
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
|
||||
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
|
||||
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
|
||||
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
|
||||
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
|
||||
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
|
||||
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
|
||||
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
|
||||
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
|
||||
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
|
||||
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
|
||||
}
|
||||
|
||||
const audioC03Sha256 = {
|
||||
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
|
||||
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
|
||||
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
|
||||
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
|
||||
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
|
||||
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
|
||||
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
|
||||
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
|
||||
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
|
||||
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
|
||||
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
|
||||
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
|
||||
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
|
||||
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
|
||||
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
|
||||
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
|
||||
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
|
||||
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
|
||||
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
|
||||
}
|
||||
|
||||
// C02/C03 cue files are retained only as rollback/source material. A single
|
||||
// cue is not a complete page track, so these assets must not pass the runtime
|
||||
// audio gate or appear as “试听本页”.
|
||||
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
|
||||
for (const [cueId, sha256] of Object.entries(audioHashes)) {
|
||||
const file = `${cueId}.mp3`
|
||||
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
|
||||
kind: 'audio',
|
||||
reviewStatus: 'reserved',
|
||||
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
|
||||
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RELEASE_ASSET_MANIFEST_VERSION,
|
||||
releaseAssets,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* C02-C15 remote full-page audio manifest v1.
|
||||
*
|
||||
* Import contract for a future reviewed batch:
|
||||
*
|
||||
* 'S01-C09-P05': Object.freeze({
|
||||
* pageId: 'S01-C09-P05',
|
||||
* chapterNumber: 9,
|
||||
* pageNumber: 5,
|
||||
* assetId: 'audio.page.s01.c09.p05',
|
||||
* durationSeconds: 42.1,
|
||||
* reviewStatus: 'approved',
|
||||
* })
|
||||
*
|
||||
* The matching releaseAssetManifest entry must independently be an approved,
|
||||
* remote-only, immutable hash-named audio asset. Automated QA, pending human
|
||||
* listening, and missing CDN records must never be relabelled as approved.
|
||||
*/
|
||||
|
||||
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
|
||||
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
// Deliberately empty until full-page tracks pass the required human reviews
|
||||
// and their immutable CDN objects are available. This is a release gate, not
|
||||
// a placeholder that the UI may treat as playable.
|
||||
const remotePageAudioPages = Object.freeze({})
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function remotePageAudioAssetId(pageId) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return ''
|
||||
return `audio.page.s01.c${match[1]}.p${match[2]}`
|
||||
}
|
||||
|
||||
function getApprovedRemotePageAudio(
|
||||
pageId,
|
||||
releaseAssets = {},
|
||||
pageManifest = remotePageAudioPages,
|
||||
) {
|
||||
const stablePageId = clean(pageId)
|
||||
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
|
||||
if (!match) return null
|
||||
|
||||
const entry = pageManifest && pageManifest[stablePageId]
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const chapterNumber = Number(match[1])
|
||||
const pageNumber = Number(match[2])
|
||||
const assetId = remotePageAudioAssetId(stablePageId)
|
||||
const durationSeconds = Number(entry.durationSeconds)
|
||||
if (
|
||||
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| entry.pageId !== stablePageId
|
||||
|| Number(entry.chapterNumber) !== chapterNumber
|
||||
|| Number(entry.pageNumber) !== pageNumber
|
||||
|| entry.assetId !== assetId
|
||||
|| !Number.isFinite(durationSeconds)
|
||||
|| durationSeconds <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const asset = releaseAssets && releaseAssets[assetId]
|
||||
const sha256 = asset && typeof asset.sha256 === 'string'
|
||||
? asset.sha256
|
||||
: ''
|
||||
const remotePath = asset && asset.remotePath
|
||||
const expectedRemotePath = (
|
||||
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
|
||||
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
|
||||
)
|
||||
if (
|
||||
!asset
|
||||
|| asset.kind !== 'audio'
|
||||
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|
||||
|| asset.localSeed !== ''
|
||||
|| !SHA256_PATTERN.test(sha256)
|
||||
|| typeof remotePath !== 'string'
|
||||
|| remotePath !== expectedRemotePath
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...entry, assetId }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REMOTE_PAGE_AUDIO_ID_PATTERN,
|
||||
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
|
||||
getApprovedRemotePageAudio,
|
||||
remotePageAudioAssetId,
|
||||
remotePageAudioPages,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "唐侦探",
|
||||
"pageOrientation": "landscape",
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
<view
|
||||
wx:if="{{chapter}}"
|
||||
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
|
||||
style="{{pageStyle}}"
|
||||
>
|
||||
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
|
||||
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
|
||||
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录">‹ 目录</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
|
||||
class="comic-top-previous-button"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一张画"
|
||||
>‹</button>
|
||||
<view class="comic-bookmark">
|
||||
<text class="comic-bookmark-label">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
|
||||
</text>
|
||||
<text class="comic-page-count">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
</view>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
|
||||
bindtap="openTextReading"
|
||||
aria-label="本页可以直接看文字"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text class="comic-page-audio-label">直接看文字</text>
|
||||
<text class="comic-page-audio-meta">按页慢慢看</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
|
||||
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
|
||||
disabled="{{audioLoading}}"
|
||||
bindtap="toggleComicPageAudio"
|
||||
data-audio-cue-count="{{currentPage.audioCueCount}}"
|
||||
aria-label="本页语音播放控制"
|
||||
>
|
||||
<text class="comic-page-audio-icon" aria-hidden="true">
|
||||
{{audioPlaying ? 'Ⅱ' : '▶'}}
|
||||
</text>
|
||||
<view class="comic-page-audio-copy">
|
||||
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
|
||||
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
|
||||
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
|
||||
<text wx:else class="comic-page-audio-label">听一听</text>
|
||||
<text class="comic-page-audio-meta">
|
||||
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
|
||||
</text>
|
||||
</view>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
|
||||
class="comic-font-button"
|
||||
bindtap="toggleFont"
|
||||
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
|
||||
>
|
||||
{{fontScale === 'large' ? '大字' : '标准字'}}
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
|
||||
class="c1-front-action-button"
|
||||
bindtap="openCurrentFrontAction"
|
||||
aria-label="{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
|
||||
: '点一下,翻到背面'}}"
|
||||
>
|
||||
{{currentPage.type === 'event'
|
||||
? (currentEventCompleted ? '再看背面' : '点点人物')
|
||||
: currentPage.type === 'emotion'
|
||||
? (chapterFinished ? '看看章末' : '听听心里话')
|
||||
: '翻到背面'}}
|
||||
</button>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view class="comic-reader">
|
||||
<block wx:if="{{storyFirstComicMode}}">
|
||||
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
|
||||
<block wx:if="{{chapterOneBackOpen}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">
|
||||
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边回声</text>
|
||||
<text>{{currentPage.memoryCard.tableEcho}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">带回生活里</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
<view class="c1-memory-secondary-actions">
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>看看生活观察</button>
|
||||
<button
|
||||
class="c1-memory-secondary-action"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>发给家人聊聊</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{chapterOneReviewEvent.evidence}}</text>
|
||||
</view>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.caption}}</text>
|
||||
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
|
||||
<text class="c1-back-label">桌边一句话</text>
|
||||
<text>“{{currentPage.shortDialogue}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text
|
||||
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
|
||||
class="c1-scroll-hint"
|
||||
>上划接着看 ↑</text>
|
||||
|
||||
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
|
||||
</view>
|
||||
<view wx:else class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
|
||||
<button
|
||||
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
|
||||
class="c1-back-button primary"
|
||||
bindtap="startChapterOneDecisionFromBack"
|
||||
>聊聊这事</button>
|
||||
<button
|
||||
wx:else
|
||||
class="c1-back-button primary"
|
||||
bindtap="nextChapterOnePageFromBack"
|
||||
>翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
|
||||
<view class="c1-back-paper">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边回声</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">没有标准答案</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{emotionResult.feedback}}</text>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">这一桌记住了</text>
|
||||
<text>{{emotionResult.echo}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="c1-back-footer">
|
||||
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
|
||||
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
|
||||
<view class="c1-interaction-paper c1-emotion-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">桌边一句话</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
|
||||
<text class="c1-tray-question">{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="c1-natural-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>{{item.text}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view class="c1-front-stage">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-front-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="c1-front-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="c1-front-grain"></view>
|
||||
|
||||
<button
|
||||
wx:if="{{!frontControlsRevealed}}"
|
||||
class="c1-front-reveal-gate"
|
||||
bindtap="revealCurrentFrontControls"
|
||||
aria-label="轻点画面,显示阅读按钮"
|
||||
></button>
|
||||
|
||||
<block wx:else>
|
||||
<button
|
||||
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
|
||||
class="c1-person-hotspot"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
|
||||
><view class="c1-hotspot-ring"></view></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="翻到这一幕的背面"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneEmotion"
|
||||
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
|
||||
></button>
|
||||
|
||||
<button
|
||||
wx:else
|
||||
class="c1-full-page-tap"
|
||||
bindtap="openChapterOneBack"
|
||||
aria-label="轻触画面,翻到这一页背面"
|
||||
></button>
|
||||
</block>
|
||||
|
||||
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
|
||||
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'emotion'}}">
|
||||
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="special-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="special-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="emotion-panel">
|
||||
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="emotion-context">
|
||||
<text class="emotion-context-kicker">听听这一桌心里话</text>
|
||||
<text class="emotion-context-title">{{currentPage.headline}}</text>
|
||||
<text class="emotion-context-caption">{{currentPage.caption}}</text>
|
||||
</view>
|
||||
<text class="emotion-prompt">{{currentPage.prompt}}</text>
|
||||
<text
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-note"
|
||||
>没有标准答案,选您更愿意说的话。</text>
|
||||
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
|
||||
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
|
||||
<view class="table-echo">
|
||||
<text class="echo-label">桌边回声</text>
|
||||
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view
|
||||
wx:if="{{!emotionResult && !chapterFinished}}"
|
||||
class="emotion-choice-dock"
|
||||
>
|
||||
<button
|
||||
wx:for="{{chapter.emotionMoment.playerChoices}}"
|
||||
wx:key="choiceId"
|
||||
class="emotion-choice"
|
||||
data-choice="{{item.choiceId}}"
|
||||
bindtap="chooseEmotion"
|
||||
>
|
||||
<text class="choice-number">{{index + 1}}</text>
|
||||
<text>{{item.text}}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
|
||||
<button
|
||||
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{emotionResult || chapterFinished}}"
|
||||
class="special-page-button primary"
|
||||
bindtap="finishComicEmotion"
|
||||
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
|
||||
>
|
||||
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.isSeasonClose}}">
|
||||
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
|
||||
<view class="season-close-body">
|
||||
<view class="season-close-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="season-close-main-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="season-close-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view
|
||||
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
|
||||
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
|
||||
aria-label="{{currentPage.detailInset.label}}近景"
|
||||
>
|
||||
<image
|
||||
class="season-close-detail-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{currentPage.detailInset.imageMode}}"
|
||||
style="{{currentPage.detailInset.imageStyle}}"
|
||||
></image>
|
||||
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="season-close-copy"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="season-close-copy-sheet">
|
||||
<text class="season-close-kicker">第一季 · 季终</text>
|
||||
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
|
||||
<view class="season-close-memory-card">
|
||||
<text class="season-close-card-label">桂香记忆卡</text>
|
||||
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
|
||||
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="season-close-life-line">
|
||||
<text class="season-close-line-label">今天可以这样做</text>
|
||||
<text>{{currentPage.memoryCard.lifeAction}}</text>
|
||||
</view>
|
||||
<view class="season-close-family-line">
|
||||
<text class="season-close-line-label">带回家聊一聊</text>
|
||||
<text>“{{currentPage.memoryCard.familyLine}}”</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="memory-actions season-close-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="第一季读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{currentPage.type === 'memory'}}">
|
||||
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
|
||||
<view class="memory-art">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="memory-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="special-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
</view>
|
||||
<view class="memory-panel">
|
||||
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
|
||||
<view class="memory-paper-content">
|
||||
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
|
||||
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
|
||||
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
|
||||
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
|
||||
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
|
||||
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
|
||||
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
|
||||
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
|
||||
</button>
|
||||
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="memory-actions">
|
||||
<button
|
||||
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="返回前一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="memory-action-visible">上页</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell report"
|
||||
bindtap="openLifeReport"
|
||||
aria-label="查看本回桂香生活观察"
|
||||
>
|
||||
<text class="memory-action-visible">生活观察</text>
|
||||
</button>
|
||||
<button
|
||||
class="memory-action-cell share"
|
||||
open-type="share"
|
||||
aria-label="发给家人,一起聊聊这一页"
|
||||
>
|
||||
<text class="memory-action-visible">发给家人</text>
|
||||
</button>
|
||||
<button
|
||||
wx:if="{{chapterNumber >= chapterCount}}"
|
||||
class="memory-action-cell primary"
|
||||
bindtap="goCatalog"
|
||||
aria-label="全书读完,回到目录"
|
||||
>
|
||||
<text class="memory-action-visible">回到目录</text>
|
||||
</button>
|
||||
<button
|
||||
wx:else
|
||||
class="memory-action-cell primary"
|
||||
bindtap="nextChapter"
|
||||
aria-label="翻到下一回"
|
||||
>
|
||||
<text class="memory-action-visible">翻到下一回</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view
|
||||
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
|
||||
bindtouchstart="onComicTouchStart"
|
||||
bindtouchend="onComicTouchEnd"
|
||||
>
|
||||
<view class="comic-art-column">
|
||||
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-art-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="{{comicPageImageMode}}"
|
||||
style="{{comicPageImageStyle}}"
|
||||
bindload="onComicImageLoad"
|
||||
binderror="onComicImageError"
|
||||
></image>
|
||||
<view wx:else class="comic-art-fallback">
|
||||
<text>{{comicImageError}}</text>
|
||||
<text>{{currentPage.sceneAlt}}</text>
|
||||
</view>
|
||||
<view class="comic-paper-grain"></view>
|
||||
<view
|
||||
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
|
||||
class="comic-evidence-composite"
|
||||
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
|
||||
>
|
||||
<text class="comic-evidence-kicker">手边证据</text>
|
||||
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
|
||||
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
|
||||
</view>
|
||||
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
|
||||
class="comic-actor-hotspot is-quiet"
|
||||
style="{{currentPage.actorHotspot.style}}"
|
||||
bindtap="openComicActor"
|
||||
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
|
||||
></button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
|
||||
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
|
||||
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
|
||||
<view class="comic-cover-caption-heading">
|
||||
<text class="comic-caption-kicker">{{chapter.year}}</text>
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
</view>
|
||||
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
|
||||
<text class="comic-caption-heading">{{currentPage.headline}}</text>
|
||||
<view class="comic-ensemble-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
|
||||
<view class="comic-event-caption-copy">
|
||||
<text class="comic-caption">{{currentPage.displayCaption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
|
||||
</view>
|
||||
<view class="comic-event-cue">
|
||||
<text
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
wx:if="{{currentInteractionEnabled && !compactHeight}}"
|
||||
class="comic-outside-actor-button"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
|
||||
<text class="seen-chip">看见了</text>
|
||||
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
wx:if="{{currentInteractionEnabled && compactHeight}}"
|
||||
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
|
||||
>
|
||||
<text
|
||||
wx:if="{{currentPage.artTapHint}}"
|
||||
class="comic-art-tap-note"
|
||||
>再看看{{currentPage.actorName}}的手边</text>
|
||||
<button
|
||||
class="comic-outside-actor-button comic-outside-actor-dock"
|
||||
bindtap="openComicActor"
|
||||
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
|
||||
>
|
||||
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
|
||||
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
|
||||
<text wx:else>看看发生了什么</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="comic-page-nav">
|
||||
<button
|
||||
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="previousComicPage"
|
||||
aria-label="上一页"
|
||||
aria-disabled="{{comicPreviousDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">上页</text>
|
||||
</button>
|
||||
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}/{{comicPageCount}}</text>
|
||||
<button
|
||||
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
|
||||
bindtap="nextComicPage"
|
||||
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
|
||||
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
|
||||
>
|
||||
<text class="comic-control-visible">
|
||||
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
|
||||
</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
|
||||
<view
|
||||
class="text-reading-dialog paper-panel"
|
||||
aria-role="dialog"
|
||||
aria-label="本页大字文字"
|
||||
>
|
||||
<button
|
||||
class="dialog-close"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="关闭文字阅读"
|
||||
>×</button>
|
||||
<text class="text-reading-kicker">
|
||||
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
|
||||
</text>
|
||||
<scroll-view
|
||||
class="text-reading-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{true}}"
|
||||
>
|
||||
<view class="text-reading-sheet">
|
||||
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
|
||||
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
|
||||
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
|
||||
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
|
||||
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">这一页想和您聊</text>
|
||||
<text>{{currentPage.prompt}}</text>
|
||||
</view>
|
||||
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
|
||||
<text class="text-reading-label">看看画里发生了什么</text>
|
||||
<text>{{currentPage.question}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<button
|
||||
class="text-reading-return"
|
||||
bindtap="closeTextReading"
|
||||
aria-label="回到连环画"
|
||||
>回到画里</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
|
||||
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">画里的线索</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
|
||||
<view class="c1-tray-heading">
|
||||
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
|
||||
<text class="c1-tray-question">{{activeEvent.question}}</text>
|
||||
</view>
|
||||
<view class="c1-choice-stack">
|
||||
<button
|
||||
wx:for="{{activeEvent.choiceOptions}}"
|
||||
wx:key="value"
|
||||
class="c1-natural-choice"
|
||||
data-value="{{item.value}}"
|
||||
bindtap="answer"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
|
||||
<view class="c1-interaction-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-interaction-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">再看看画里</text>
|
||||
</view>
|
||||
<view class="c1-interaction-panel c1-retry-panel">
|
||||
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
|
||||
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
|
||||
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
|
||||
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="c1-back-paper c1-event-result-back">
|
||||
<view class="c1-back-evidence">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="c1-back-image"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
|
||||
<text class="c1-back-stamp">这下有眉目了</text>
|
||||
</view>
|
||||
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="c1-back-copy-inner">
|
||||
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
|
||||
<text class="c1-back-title">{{currentPage.headline}}</text>
|
||||
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
|
||||
<text class="c1-back-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<block wx:else>
|
||||
<view class="c1-back-callout">
|
||||
<text class="c1-back-label">您刚才瞧见的</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
<text class="c1-back-reason">{{activeEvent.reason}}</text>
|
||||
<view class="c1-back-action">
|
||||
<text class="c1-back-label">下回遇到这事</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text class="c1-scroll-hint">上划接着看 ↑</text>
|
||||
<view class="c1-back-footer">
|
||||
<button
|
||||
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="c1-back-button secondary"
|
||||
aria-label="回头再看一遍画面"
|
||||
bindtap="returnFromChapterOneDecision"
|
||||
>回去看画</button>
|
||||
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
|
||||
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
|
||||
<view
|
||||
class="decision-dialog paper-panel comic-decision"
|
||||
aria-role="dialog"
|
||||
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
|
||||
>
|
||||
<button
|
||||
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
|
||||
class="dialog-close"
|
||||
bindtap="closeDecision"
|
||||
aria-label="回到画里"
|
||||
>×</button>
|
||||
|
||||
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
|
||||
<view class="character-story-inner">
|
||||
<view class="comic-focus-visual">
|
||||
<image
|
||||
wx:if="{{comicImageSrc}}"
|
||||
class="comic-focus-scene"
|
||||
src="{{comicImageSrc}}"
|
||||
mode="aspectFit"
|
||||
style="{{comicFocusImageStyle}}"
|
||||
></image>
|
||||
<view class="comic-focus-shade"></view>
|
||||
<view class="comic-focus-name">
|
||||
<text>{{activePerson.eraLabel}} · 画中人物</text>
|
||||
<text class="focus-name">{{activeEvent.actorName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="story-label">这一幕发生了什么</text>
|
||||
<text class="action-description">{{activeEvent.actionDescription}}</text>
|
||||
|
||||
<view class="evidence-box">
|
||||
<text class="story-label">手边证据</text>
|
||||
<text>{{activeEvent.evidence}}</text>
|
||||
</view>
|
||||
|
||||
<view class="speech-box">
|
||||
<text class="story-label">桌边说话</text>
|
||||
<text>{{activeEvent.speech}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
|
||||
<scroll-view
|
||||
class="judgement-scroll"
|
||||
scroll-y
|
||||
enhanced
|
||||
show-scrollbar="{{decisionResultNeedsScroll}}"
|
||||
>
|
||||
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
|
||||
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
|
||||
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
|
||||
|
||||
<view wx:if="{{resultMode === ''}}" class="judgement-options">
|
||||
<button class="judgement-button observe" data-value="caution" bindtap="answer">
|
||||
<text class="judgement-icon">A</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">先看看现场细节</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="judgement-button continue" data-value="safer" bindtap="answer">
|
||||
<text class="judgement-icon">B</text>
|
||||
<view class="judgement-button-copy">
|
||||
<text class="judgement-title">继续按画里的做法</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
|
||||
<text class="result-title">没关系,再看看画里的细节。</text>
|
||||
<text>{{activeEvent.retryHint}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
|
||||
<text class="result-title">这处线索对上了。</text>
|
||||
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
|
||||
<text class="advice-label">先把眼前这一步做好</text>
|
||||
<text>先停酒、移开危险物,并留下人陪伴。</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
|
||||
<view wx:else class="action-advice">
|
||||
<text class="advice-label">现在可以怎么做</text>
|
||||
<text>{{activeEvent.actionAdvice}}</text>
|
||||
</view>
|
||||
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
|
||||
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
|
||||
</view>
|
||||
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="openResultDetail">
|
||||
再看不同情况怎么办
|
||||
</button>
|
||||
</view>
|
||||
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
|
||||
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
|
||||
回到画里
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
|
||||
contractId: 'story-first-three-frame-v1',
|
||||
columns: Object.freeze({
|
||||
art: 3,
|
||||
content: 2,
|
||||
}),
|
||||
frames: Object.freeze({
|
||||
front: Object.freeze({
|
||||
imageMode: 'aspectFit',
|
||||
initialControls: 'none',
|
||||
revealGesture: 'single-tap',
|
||||
revealedControls: 'topbar-only',
|
||||
}),
|
||||
back: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
}),
|
||||
interaction: Object.freeze({
|
||||
art: 2,
|
||||
content: 3,
|
||||
imageMode: 'aspectFit',
|
||||
choiceCount: 2,
|
||||
choiceDirection: 'vertical',
|
||||
}),
|
||||
}),
|
||||
minimumControlPx: 48,
|
||||
compactHeightMaxPx: 620,
|
||||
fontModes: Object.freeze({
|
||||
standard: 'large',
|
||||
xlarge: 'xlarge',
|
||||
}),
|
||||
audioSlot: Object.freeze({
|
||||
placement: 'unified-topbar',
|
||||
minimumHeightPx: 48,
|
||||
textModeLabel: '直接看文字',
|
||||
readyLabel: '听一听',
|
||||
playingLabel: '暂停',
|
||||
replayLabel: '重听',
|
||||
usesPageAudioCueIds: true,
|
||||
}),
|
||||
artOverlayPolicy: Object.freeze({
|
||||
event: 'transparent-actor-hotspot-only',
|
||||
emotion: 'transparent-actor-hotspot-only',
|
||||
}),
|
||||
})
|
||||
|
||||
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
|
||||
cover: 'chapter-ensemble',
|
||||
ensemble: 'chapter-ensemble',
|
||||
event: 'actor-event',
|
||||
emotion: 'emotion-interaction',
|
||||
memory: 'memory-cliffhanger',
|
||||
})
|
||||
|
||||
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
|
||||
'S01-C01': 'front-back-comic-v1',
|
||||
'S01-C02': 'front-back-comic-v1',
|
||||
'S01-C03': 'front-back-comic-v1',
|
||||
'S01-C04': 'front-back-comic-v1',
|
||||
'S01-C05': 'front-back-comic-v1',
|
||||
'S01-C06': 'front-back-comic-v1',
|
||||
'S01-C07': 'front-back-comic-v1',
|
||||
'S01-C08': 'front-back-comic-v1',
|
||||
'S01-C09': 'front-back-comic-v1',
|
||||
'S01-C10': 'front-back-comic-v1',
|
||||
'S01-C11': 'front-back-comic-v1',
|
||||
'S01-C12': 'front-back-comic-v1',
|
||||
'S01-C13': 'front-back-comic-v1',
|
||||
'S01-C14': 'front-back-comic-v1',
|
||||
'S01-C15': 'front-back-comic-v1',
|
||||
})
|
||||
|
||||
function getComicPresentationMode(chapterId) {
|
||||
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|
||||
|| 'legacy-60-40-v1'
|
||||
}
|
||||
|
||||
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
|
||||
'S01-C09-P03': Object.freeze({
|
||||
layoutVariant: 'standard-landscape-60-40',
|
||||
behavior: 'key-safe-actor-hotspot',
|
||||
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
|
||||
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
|
||||
}),
|
||||
})
|
||||
|
||||
// These pages keep the full reviewed frame and add one read-only crop from
|
||||
// the same asset as a second comic panel. This is an art-reading aid, not a
|
||||
// new interaction or a page-layout exception.
|
||||
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
|
||||
'S01-C04-P04',
|
||||
'S01-C09-P01',
|
||||
'S01-C09-P05',
|
||||
'S01-C09-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C13-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function allowsFixedDetailInset(pageId) {
|
||||
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
|
||||
String(pageId || ''),
|
||||
) >= 0
|
||||
}
|
||||
|
||||
function getComicPageLayoutException(pageId) {
|
||||
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
|
||||
}
|
||||
|
||||
function resolveComicPageLayout(pageId, type) {
|
||||
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
|
||||
const exception = getComicPageLayoutException(pageId)
|
||||
return {
|
||||
templateKey,
|
||||
layoutVariant: exception
|
||||
? exception.layoutVariant
|
||||
: 'standard-landscape-60-40',
|
||||
layoutExceptionId: exception ? String(pageId) : '',
|
||||
layoutExceptionBehavior: exception ? exception.behavior : '',
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_READER_LAYOUT_CONTRACT,
|
||||
COMIC_PRESENTATION_BY_CHAPTER,
|
||||
COMIC_PAGE_TEMPLATE_BY_TYPE,
|
||||
COMIC_PAGE_LAYOUT_EXCEPTIONS,
|
||||
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
|
||||
allowsFixedDetailInset,
|
||||
getComicPresentationMode,
|
||||
getComicPageLayoutException,
|
||||
resolveComicPageLayout,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
const CACHE_INDEX_VERSION = 2
|
||||
const CACHE_FOLDER = 'tang-detective-assets-v2'
|
||||
const CACHE_INDEX_FILE = 'index.json'
|
||||
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_MAX_ENTRIES = 32
|
||||
|
||||
const SHA256_ROUND_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function utf8Bytes(value) {
|
||||
const encoded = encodeURIComponent(String(value || ''))
|
||||
const bytes = []
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] === '%') {
|
||||
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
|
||||
index += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
if (typeof value === 'string') return utf8Bytes(value)
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
throw new Error('unsupported SHA-256 input')
|
||||
}
|
||||
|
||||
function rotateRight(value, bits) {
|
||||
return (value >>> bits) | (value << (32 - bits))
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
const bytes = toBytes(value)
|
||||
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||
const message = new Uint8Array(paddedLength)
|
||||
message.set(bytes)
|
||||
message[bytes.length] = 0x80
|
||||
const bitLength = bytes.length * 8
|
||||
const highBits = Math.floor(bitLength / 0x100000000)
|
||||
const lowBits = bitLength >>> 0
|
||||
const lengthOffset = paddedLength - 8
|
||||
message[lengthOffset] = (highBits >>> 24) & 0xff
|
||||
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
|
||||
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
|
||||
message[lengthOffset + 3] = highBits & 0xff
|
||||
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
|
||||
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
|
||||
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
|
||||
message[lengthOffset + 7] = lowBits & 0xff
|
||||
|
||||
const hash = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]
|
||||
const words = new Uint32Array(64)
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const start = offset + index * 4
|
||||
words[index] = (
|
||||
(message[start] << 24)
|
||||
| (message[start + 1] << 16)
|
||||
| (message[start + 2] << 8)
|
||||
| message[start + 3]
|
||||
) >>> 0
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const word15 = words[index - 15]
|
||||
const word2 = words[index - 2]
|
||||
const sigma0 = (
|
||||
rotateRight(word15, 7)
|
||||
^ rotateRight(word15, 18)
|
||||
^ (word15 >>> 3)
|
||||
)
|
||||
const sigma1 = (
|
||||
rotateRight(word2, 17)
|
||||
^ rotateRight(word2, 19)
|
||||
^ (word2 >>> 10)
|
||||
)
|
||||
words[index] = (
|
||||
words[index - 16]
|
||||
+ sigma0
|
||||
+ words[index - 7]
|
||||
+ sigma1
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
let a = hash[0]
|
||||
let b = hash[1]
|
||||
let c = hash[2]
|
||||
let d = hash[3]
|
||||
let e = hash[4]
|
||||
let f = hash[5]
|
||||
let g = hash[6]
|
||||
let h = hash[7]
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
||||
const choice = (e & f) ^ (~e & g)
|
||||
const temp1 = (
|
||||
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
|
||||
) >>> 0
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (sum0 + majority) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0
|
||||
hash[1] = (hash[1] + b) >>> 0
|
||||
hash[2] = (hash[2] + c) >>> 0
|
||||
hash[3] = (hash[3] + d) >>> 0
|
||||
hash[4] = (hash[4] + e) >>> 0
|
||||
hash[5] = (hash[5] + f) >>> 0
|
||||
hash[6] = (hash[6] + g) >>> 0
|
||||
hash[7] = (hash[7] + h) >>> 0
|
||||
}
|
||||
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||
}
|
||||
|
||||
function constantTimeEqualHex(left, right) {
|
||||
const leftValue = String(left || '').toLowerCase()
|
||||
const rightValue = String(right || '').toLowerCase()
|
||||
const length = Math.max(leftValue.length, rightValue.length)
|
||||
let difference = leftValue.length ^ rightValue.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
|
||||
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
|
||||
difference |= leftCode ^ rightCode
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function joinPath(left, right) {
|
||||
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function normalizeIndex(value) {
|
||||
if (
|
||||
!value
|
||||
|| value.version !== CACHE_INDEX_VERSION
|
||||
|| !value.entries
|
||||
|| typeof value.entries !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: CACHE_INDEX_VERSION,
|
||||
entries: {},
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function callFs(fs, method, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs || typeof fs[method] !== 'function') {
|
||||
reject(new Error(`fs.${method} unavailable`))
|
||||
return
|
||||
}
|
||||
fs[method]({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createAssetPlatformFacade() {
|
||||
if (typeof wx === 'undefined') return null
|
||||
const env = Object.freeze({
|
||||
USER_DATA_PATH: String(
|
||||
wx.env && wx.env.USER_DATA_PATH || '',
|
||||
),
|
||||
})
|
||||
return Object.freeze({
|
||||
downloadFile(options) {
|
||||
if (typeof wx.downloadFile !== 'function') {
|
||||
if (options && typeof options.fail === 'function') {
|
||||
options.fail(new Error('wx.downloadFile unavailable'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return wx.downloadFile(options)
|
||||
},
|
||||
getFileSystemManager() {
|
||||
if (typeof wx.getFileSystemManager !== 'function') return null
|
||||
return wx.getFileSystemManager()
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
function download(assetPlatform, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
|
||||
reject(new Error('wx.downloadFile unavailable'))
|
||||
return
|
||||
}
|
||||
assetPlatform.downloadFile({
|
||||
url,
|
||||
success(result) {
|
||||
if (
|
||||
result
|
||||
&& result.statusCode >= 200
|
||||
&& result.statusCode < 300
|
||||
&& result.tempFilePath
|
||||
) {
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
reject(new Error(`download status ${result && result.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function safeCacheName(assetId, asset) {
|
||||
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
|
||||
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
|
||||
}
|
||||
|
||||
function createAssetManager(options = {}) {
|
||||
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
|
||||
const manifest = options.manifest || {}
|
||||
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
|
||||
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
|
||||
? Math.max(0, options.imageCacheBudgetBytes)
|
||||
: DEFAULT_IMAGE_BUDGET
|
||||
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
|
||||
? Math.max(0, options.audioCacheBudgetBytes)
|
||||
: DEFAULT_AUDIO_BUDGET
|
||||
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
|
||||
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
|
||||
: DEFAULT_AUDIO_MAX_ENTRIES
|
||||
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
|
||||
? Math.max(1, Math.floor(options.downloadConcurrency))
|
||||
: 2
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now
|
||||
const fs = assetPlatform
|
||||
&& typeof assetPlatform.getFileSystemManager === 'function'
|
||||
? assetPlatform.getFileSystemManager()
|
||||
: null
|
||||
const userDataPath = (
|
||||
assetPlatform
|
||||
&& assetPlatform.env
|
||||
&& assetPlatform.env.USER_DATA_PATH
|
||||
) || ''
|
||||
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
|
||||
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
|
||||
|
||||
let initialized = false
|
||||
let index = normalizeIndex(null)
|
||||
let activeDownloads = 0
|
||||
const inflight = new Map()
|
||||
const downloadWaiters = []
|
||||
|
||||
function fallback(assetId, reason) {
|
||||
return {
|
||||
assetId,
|
||||
available: false,
|
||||
uri: '',
|
||||
source: 'text-fallback',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCacheFolder() {
|
||||
if (!cacheRoot) return false
|
||||
try {
|
||||
await callFs(fs, 'mkdir', {
|
||||
dirPath: cacheRoot,
|
||||
recursive: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// EEXIST and older base-library mkdir failures are both safe to ignore.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function persistIndex() {
|
||||
if (!indexPath) return
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
await callFs(fs, 'writeFile', {
|
||||
filePath: indexPath,
|
||||
data: JSON.stringify(index),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
} catch (error) {
|
||||
// Cache metadata must never block the text-first game.
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
await callFs(fs, 'access', { path: filePath })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMatchesSha256(filePath, expectedSha256) {
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', { filePath })
|
||||
return constantTimeEqualHex(
|
||||
sha256Hex(result.data),
|
||||
expectedSha256,
|
||||
)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (!indexPath) return
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', {
|
||||
filePath: indexPath,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
index = normalizeIndex(JSON.parse(result.data))
|
||||
} catch (error) {
|
||||
index = normalizeIndex(null)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [assetId, entry] of Object.entries(index.entries)) {
|
||||
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await persistIndex()
|
||||
await enforceImageBudget()
|
||||
await enforceAudioBudget()
|
||||
}
|
||||
|
||||
function cacheBytes(kind) {
|
||||
return Object.values(index.entries).reduce(
|
||||
(total, entry) => (
|
||||
entry.kind === kind
|
||||
? total + Math.max(0, Number(entry.size) || 0)
|
||||
: total
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function cacheEntries(kind) {
|
||||
return Object.values(index.entries)
|
||||
.filter((entry) => entry.kind === kind)
|
||||
.length
|
||||
}
|
||||
|
||||
function imageCacheBytes() {
|
||||
return cacheBytes('image')
|
||||
}
|
||||
|
||||
function audioCacheBytes() {
|
||||
return cacheBytes('audio')
|
||||
}
|
||||
|
||||
async function unlinkQuietly(filePath) {
|
||||
try {
|
||||
await callFs(fs, 'unlink', { filePath })
|
||||
} catch (error) {
|
||||
// A missing cache file is already evicted.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceImageBudget(protectedAssetId = '') {
|
||||
let total = imageCacheBytes()
|
||||
if (total <= imageBudgetBytes) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'image' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= imageBudgetBytes) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function enforceAudioBudget(protectedAssetId = '') {
|
||||
let total = audioCacheBytes()
|
||||
let entries = cacheEntries('audio')
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'audio' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
entries -= 1
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function resolveCached(assetId, asset) {
|
||||
const entry = index.entries[assetId]
|
||||
if (!entry || entry.sha256 !== asset.sha256) return null
|
||||
if (!(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return null
|
||||
}
|
||||
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
|
||||
await unlinkQuietly(entry.filePath)
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
entry.lastAccessedAt = now()
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: entry.filePath,
|
||||
source: 'cache',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTempFileSize(tempFilePath, response) {
|
||||
if (Number.isFinite(response.fileSize)) return response.fileSize
|
||||
try {
|
||||
const statResult = await callFs(fs, 'stat', {
|
||||
path: tempFilePath,
|
||||
})
|
||||
const stat = statResult && statResult.stats
|
||||
return Math.max(0, Number(stat && stat.size) || 0)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(assetId, asset) {
|
||||
if (!cdnBaseUrl || !asset.remotePath) {
|
||||
return fallback(assetId, 'remote-disabled')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
activeDownloads += 1
|
||||
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1
|
||||
const next = downloadWaiters.shift()
|
||||
if (next) next()
|
||||
})
|
||||
}
|
||||
if (activeDownloads < downloadConcurrency) run()
|
||||
else downloadWaiters.push(run)
|
||||
})
|
||||
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
|
||||
await unlinkQuietly(response.tempFilePath)
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
const size = await getTempFileSize(response.tempFilePath, response)
|
||||
|
||||
const exceedsCachePolicy = (
|
||||
(asset.kind === 'image' && size > imageBudgetBytes)
|
||||
|| (
|
||||
asset.kind === 'audio'
|
||||
&& (size > audioBudgetBytes || audioMaxEntries < 1)
|
||||
)
|
||||
)
|
||||
if (!cacheRoot || exceedsCachePolicy) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
|
||||
await callFs(fs, 'saveFile', {
|
||||
tempFilePath: response.tempFilePath,
|
||||
filePath,
|
||||
})
|
||||
index.entries[assetId] = {
|
||||
assetId,
|
||||
filePath,
|
||||
kind: asset.kind,
|
||||
sha256: asset.sha256,
|
||||
size,
|
||||
lastAccessedAt: now(),
|
||||
}
|
||||
await enforceImageBudget(assetId)
|
||||
await enforceAudioBudget(assetId)
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: filePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(assetId, resolveOptions = {}) {
|
||||
await init()
|
||||
const asset = manifest[assetId]
|
||||
if (!asset) return fallback(assetId, 'unknown-asset')
|
||||
if (
|
||||
asset.kind === 'audio'
|
||||
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
|
||||
) {
|
||||
return fallback(assetId, 'audio-unapproved')
|
||||
}
|
||||
|
||||
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
|
||||
if (asset.localSeed && resolveOptions.preferRemote !== true) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
const cached = await resolveCached(assetId, asset)
|
||||
if (cached) return cached
|
||||
|
||||
if (resolveOptions.allowRemote === false) {
|
||||
if (asset.localSeed) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
return fallback(assetId, 'remote-disallowed')
|
||||
}
|
||||
|
||||
if (inflight.has(assetId)) return inflight.get(assetId)
|
||||
|
||||
const request = fetchRemote(assetId, asset)
|
||||
.catch(() => (
|
||||
asset.localSeed
|
||||
? {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
: fallback(assetId, 'remote-failed')
|
||||
))
|
||||
.finally(() => inflight.delete(assetId))
|
||||
inflight.set(assetId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function prefetch(assetIds) {
|
||||
const ids = Array.isArray(assetIds) ? assetIds : []
|
||||
return Promise.all(ids.map((assetId) => resolve(assetId)))
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await init()
|
||||
const entries = Object.values(index.entries)
|
||||
for (const entry of entries) await unlinkQuietly(entry.filePath)
|
||||
index = normalizeIndex(null)
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return {
|
||||
entries: Object.keys(index.entries).length,
|
||||
imageBytes: imageCacheBytes(),
|
||||
imageBudgetBytes,
|
||||
audioEntries: cacheEntries('audio'),
|
||||
audioBytes: audioCacheBytes(),
|
||||
audioBudgetBytes,
|
||||
audioMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
resolve,
|
||||
prefetch,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
getAsset(assetId) {
|
||||
return manifest[assetId] || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_INDEX_VERSION,
|
||||
DEFAULT_AUDIO_BUDGET,
|
||||
DEFAULT_AUDIO_MAX_ENTRIES,
|
||||
constantTimeEqualHex,
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
sha256Hex,
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
const REVIEWED_AUDIO_STATUS = 'approved'
|
||||
const REVIEWED_VISUAL_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_STATUS = 'experience-provisional'
|
||||
const PROVISIONAL_VISUAL_TIER = 'experience-provisional'
|
||||
const IMAGE_UNAVAILABLE_MESSAGE = '本页画面暂时没有打开,文字和互动仍可继续。'
|
||||
const AUDIO_FILE_PATTERN = /\.(?:aac|m4a|mp3|ogg|wav)$/i
|
||||
|
||||
function cleanPath(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isPlayableVisualDeclaration(visual) {
|
||||
if (!visual || typeof visual !== 'object') return false
|
||||
const reviewStatus = cleanPath(visual.reviewStatus).toLowerCase()
|
||||
if (reviewStatus === REVIEWED_VISUAL_STATUS) return true
|
||||
return Boolean(
|
||||
visual.kind === 'page-art'
|
||||
&& cleanPath(visual.runtimeTier) === PROVISIONAL_VISUAL_TIER
|
||||
&& visual.formalReleaseEligible === false
|
||||
&& reviewStatus === PROVISIONAL_VISUAL_STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
function isPackagedPath(value) {
|
||||
const path = cleanPath(value)
|
||||
return (
|
||||
path.startsWith('/assets/')
|
||||
|| /^\/package-[a-z0-9-]+\//i.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
function actorSheetFocusPercent(person) {
|
||||
const era = String(person && person.eraLabel || '')
|
||||
const characterId = String(person && person.characterId || '')
|
||||
if (characterId === 'qin-xiaoman') {
|
||||
if (/2008/.test(era)) return 16
|
||||
if (/2026/.test(era)) return 65
|
||||
}
|
||||
if (characterId === 'tang-mingyuan') {
|
||||
if (/1995/.test(era)) return 11
|
||||
if (/2001/.test(era)) return 37
|
||||
if (/2003/.test(era)) return 63
|
||||
if (/2026/.test(era)) return 88
|
||||
}
|
||||
if (/1978|1980/.test(era)) return 11
|
||||
if (/1993|1995|1998/.test(era)) return 31
|
||||
if (/2001|2003/.test(era)) return 50
|
||||
if (/2008/.test(era)) return 69
|
||||
if (/2026/.test(era)) return 88
|
||||
return 50
|
||||
}
|
||||
|
||||
function isSinglePortraitFallback(person) {
|
||||
return String(person && person.characterId || '') === 'female-cook'
|
||||
}
|
||||
|
||||
function getComicPageImageMode(person, usingActorFallback) {
|
||||
return usingActorFallback && isSinglePortraitFallback(person)
|
||||
? 'aspectFit'
|
||||
: 'scaleToFill'
|
||||
}
|
||||
|
||||
function getComicPageImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const focusX = actorSheetFocusPercent(person)
|
||||
const translateX = 50 - (3.5 * focusX)
|
||||
return [
|
||||
'transform-origin:0 0',
|
||||
`transform:translate(${translateX}%,-30%) scale(3.5)`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
function getComicFocusImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const sourceFocusX = actorSheetFocusPercent(person)
|
||||
const focusX = 25 + (sourceFocusX * 0.5)
|
||||
return [
|
||||
'transform:scale(7)',
|
||||
`transform-origin:${focusX}% 12%`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first image shown by a comic page.
|
||||
*
|
||||
* A release-manifest localSeed is authoritative and must never wait for the
|
||||
* network. A remote-only illustration starts on the reviewed scene fallback,
|
||||
* so a slow or failed download cannot leave a blank reader.
|
||||
*/
|
||||
function buildComicImageState(page = {}, releaseAsset = null, previous = {}) {
|
||||
const illustrationAsset = cleanPath(page.illustrationAsset)
|
||||
const fallbackAsset = cleanPath(page.fallbackAsset)
|
||||
const playableVisual = isPlayableVisualDeclaration(page.playableVisual)
|
||||
? page.playableVisual
|
||||
: null
|
||||
const sharedVisualKind = playableVisual
|
||||
&& playableVisual.kind !== 'page-art'
|
||||
? cleanPath(playableVisual.kind)
|
||||
: ''
|
||||
const sharedVisualAsset = sharedVisualKind
|
||||
? cleanPath(playableVisual.asset)
|
||||
: ''
|
||||
const actorFallbackAsset = cleanPath(
|
||||
(
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
? sharedVisualAsset
|
||||
: (page.actorFallbackAsset || page.actorPortrait),
|
||||
)
|
||||
const localSeed = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.localSeed)
|
||||
: ''
|
||||
const remotePath = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.remotePath)
|
||||
: ''
|
||||
const keepFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& fallbackAsset
|
||||
)
|
||||
const keepActorFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& previous.comicImageSource === 'actor-fallback'
|
||||
&& actorFallbackAsset
|
||||
)
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& (
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: sharedVisualAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'shared-portrait',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& sharedVisualKind === 'evidence-composite'
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'shared-evidence',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepActorFallback) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepFallback) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (localSeed) {
|
||||
return {
|
||||
src: localSeed,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'local-seed',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (remotePath) {
|
||||
const loadingFallback = actorFallbackAsset || fallbackAsset
|
||||
const usingActorFallback = Boolean(actorFallbackAsset)
|
||||
return {
|
||||
src: loadingFallback,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: Boolean(loadingFallback),
|
||||
usingActorFallback,
|
||||
source: usingActorFallback
|
||||
? 'actor-fallback-loading'
|
||||
: (fallbackAsset ? 'fallback-loading' : 'text-fallback-loading'),
|
||||
error: loadingFallback ? '' : IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseAsset && illustrationAsset) {
|
||||
return {
|
||||
src: illustrationAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'declared-local-path',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (actorFallbackAsset) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
src: '',
|
||||
fallback: '',
|
||||
actorFallback: '',
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'text-fallback',
|
||||
error: IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio is intentionally fail-closed. A non-empty src alone is not enough:
|
||||
* both the story data and immutable release manifest must mark the file as
|
||||
* approved, and the playable src must be the packaged, hash-checked seed.
|
||||
*/
|
||||
function getReviewedAudioSrc(media = {}, manifest = {}) {
|
||||
const status = cleanPath(media.status || media.audioStatus).toLowerCase()
|
||||
const assetId = cleanPath(media.assetId || media.audioAssetId)
|
||||
const declaredSrc = cleanPath(media.src || media.audioSrc)
|
||||
if (
|
||||
status !== REVIEWED_AUDIO_STATUS
|
||||
|| !assetId
|
||||
|| !declaredSrc
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const releaseAsset = manifest[assetId]
|
||||
if (
|
||||
!releaseAsset
|
||||
|| releaseAsset.kind !== 'audio'
|
||||
|| cleanPath(releaseAsset.reviewStatus).toLowerCase()
|
||||
!== REVIEWED_AUDIO_STATUS
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const localSeed = cleanPath(releaseAsset.localSeed)
|
||||
if (
|
||||
!localSeed
|
||||
|| localSeed !== declaredSrc
|
||||
|| !isPackagedPath(localSeed)
|
||||
|| !AUDIO_FILE_PATTERN.test(localSeed)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return localSeed
|
||||
}
|
||||
|
||||
function buildReviewedAudioState(media = {}, manifest = {}) {
|
||||
const src = getReviewedAudioSrc(media, manifest)
|
||||
return {
|
||||
available: Boolean(src),
|
||||
src,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDIO_FILE_PATTERN,
|
||||
IMAGE_UNAVAILABLE_MESSAGE,
|
||||
REVIEWED_AUDIO_STATUS,
|
||||
buildComicImageState,
|
||||
buildReviewedAudioState,
|
||||
getComicFocusImageStyle,
|
||||
getComicPageImageMode,
|
||||
getComicPageImageStyle,
|
||||
getReviewedAudioSrc,
|
||||
isPackagedPath,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
const COMIC_PAGE_TYPES = Object.freeze([
|
||||
'cover',
|
||||
'ensemble',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'emotion',
|
||||
'memory',
|
||||
])
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicPages(model = {}) {
|
||||
return Array.isArray(model.pageSequence) ? model.pageSequence : []
|
||||
}
|
||||
|
||||
function pageIndex(model, pageId) {
|
||||
const requested = cleanString(pageId)
|
||||
if (!requested) return -1
|
||||
return comicPages(model).findIndex((page) => page.pageId === requested)
|
||||
}
|
||||
|
||||
function eventPages(model) {
|
||||
return comicPages(model).filter((page) => page.type === 'event')
|
||||
}
|
||||
|
||||
function isEightPageComicModel(model = {}) {
|
||||
const pages = comicPages(model)
|
||||
if (
|
||||
model.mode !== 'comic'
|
||||
|| !cleanString(model.chapterId)
|
||||
|| pages.length !== COMIC_PAGE_TYPES.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return pages.every((page, index) => (
|
||||
page
|
||||
&& page.pageId === `${model.chapterId}-P${String(index + 1).padStart(2, '0')}`
|
||||
&& page.type === COMIC_PAGE_TYPES[index]
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a contiguous prefix of P03-P06 is trusted.
|
||||
*
|
||||
* This prevents stale, foreign, duplicated or out-of-order ids from opening a
|
||||
* later page. It also keeps the locked 60-event source order unchanged.
|
||||
*/
|
||||
function normalizeCompletedEventIds(model, completedIds = []) {
|
||||
const supplied = new Set(
|
||||
(Array.isArray(completedIds) ? completedIds : [])
|
||||
.map(cleanString)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const normalized = []
|
||||
for (const page of eventPages(model)) {
|
||||
if (!supplied.has(page.eventId)) break
|
||||
normalized.push(page.eventId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getUnlockedPageIndex(
|
||||
model,
|
||||
completedIds = [],
|
||||
chapterFinished = false,
|
||||
) {
|
||||
const pages = comicPages(model)
|
||||
if (!pages.length) return -1
|
||||
if (!isEightPageComicModel(model)) return 0
|
||||
if (chapterFinished) return pages.length - 1
|
||||
|
||||
const completed = normalizeCompletedEventIds(model, completedIds)
|
||||
const firstEventIndex = pages.findIndex((page) => page.type === 'event')
|
||||
const emotionIndex = pages.findIndex((page) => page.type === 'emotion')
|
||||
if (firstEventIndex < 0) return 0
|
||||
|
||||
return Math.min(
|
||||
emotionIndex >= 0 ? emotionIndex : pages.length - 1,
|
||||
firstEventIndex + completed.length,
|
||||
)
|
||||
}
|
||||
|
||||
function emptyReaderState() {
|
||||
return {
|
||||
valid: false,
|
||||
currentPage: null,
|
||||
currentPageId: '',
|
||||
currentPageIndex: -1,
|
||||
unlockedPageIndex: -1,
|
||||
unlockedPageIds: [],
|
||||
completedEventIds: [],
|
||||
allEventsComplete: false,
|
||||
chapterFinished: false,
|
||||
canGoPrevious: false,
|
||||
canGoNext: false,
|
||||
nextPageLocked: false,
|
||||
activeInteraction: null,
|
||||
activeHotspots: [],
|
||||
pageAccess: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildComicReaderState(model, progress = {}) {
|
||||
if (!isEightPageComicModel(model)) return emptyReaderState()
|
||||
|
||||
const pages = comicPages(model)
|
||||
const completedEventIds = normalizeCompletedEventIds(
|
||||
model,
|
||||
progress.completedEventIds || progress.completedIds,
|
||||
)
|
||||
const allEventsComplete = (
|
||||
completedEventIds.length === eventPages(model).length
|
||||
)
|
||||
const chapterFinished = Boolean(
|
||||
progress.chapterFinished && allEventsComplete,
|
||||
)
|
||||
const unlockedPageIndex = getUnlockedPageIndex(
|
||||
model,
|
||||
completedEventIds,
|
||||
chapterFinished,
|
||||
)
|
||||
const requestedPageId = (
|
||||
cleanString(progress.currentPageId)
|
||||
|| cleanString(progress.savedPageId)
|
||||
|| model.firstPageId
|
||||
|| pages[0].pageId
|
||||
)
|
||||
const requestedIndex = pageIndex(model, requestedPageId)
|
||||
const currentPageIndex = Math.min(
|
||||
unlockedPageIndex,
|
||||
Math.max(0, requestedIndex >= 0 ? requestedIndex : 0),
|
||||
)
|
||||
const currentPage = pages[currentPageIndex]
|
||||
const completed = new Set(completedEventIds)
|
||||
|
||||
let activeInteraction = null
|
||||
if (
|
||||
currentPage.type === 'event'
|
||||
&& !completed.has(currentPage.eventId)
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'event',
|
||||
id: currentPage.eventId,
|
||||
eventId: currentPage.eventId,
|
||||
pageId: currentPage.pageId,
|
||||
actorInstanceId: currentPage.actorInstanceId || '',
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
} else if (
|
||||
currentPage.type === 'emotion'
|
||||
&& allEventsComplete
|
||||
&& !chapterFinished
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'emotion',
|
||||
id: currentPage.emotionMomentId,
|
||||
emotionMomentId: currentPage.emotionMomentId,
|
||||
pageId: currentPage.pageId,
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
}
|
||||
|
||||
const activeHotspots = (
|
||||
activeInteraction
|
||||
&& activeInteraction.actorHotspot
|
||||
)
|
||||
? [{
|
||||
interactionId: activeInteraction.id,
|
||||
pageId: activeInteraction.pageId,
|
||||
actorHotspot: activeInteraction.actorHotspot,
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
currentPage,
|
||||
currentPageId: currentPage.pageId,
|
||||
currentPageIndex,
|
||||
unlockedPageIndex,
|
||||
unlockedPageIds: pages
|
||||
.slice(0, unlockedPageIndex + 1)
|
||||
.map((page) => page.pageId),
|
||||
completedEventIds,
|
||||
allEventsComplete,
|
||||
chapterFinished,
|
||||
canGoPrevious: currentPageIndex > 0,
|
||||
canGoNext: currentPageIndex < unlockedPageIndex,
|
||||
nextPageLocked: (
|
||||
currentPageIndex < pages.length - 1
|
||||
&& currentPageIndex >= unlockedPageIndex
|
||||
),
|
||||
activeInteraction,
|
||||
activeHotspots,
|
||||
pageAccess: pages.map((page, index) => ({
|
||||
pageId: page.pageId,
|
||||
pageIndex: index,
|
||||
type: page.type,
|
||||
unlocked: index <= unlockedPageIndex,
|
||||
current: index === currentPageIndex,
|
||||
interactionEnabled: Boolean(
|
||||
activeInteraction
|
||||
&& activeInteraction.pageId === page.pageId
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function progressFromState(state) {
|
||||
return {
|
||||
currentPageId: state.currentPageId,
|
||||
completedEventIds: [...state.completedEventIds],
|
||||
chapterFinished: state.chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one chapter from the shared v1 storage object.
|
||||
*
|
||||
* `comicReaderByChapter` is the canonical reader cursor. The older
|
||||
* completedHotspots/completedChapters/lastPageId fields remain mirrored for
|
||||
* catalog compatibility and safe migration from already-installed builds.
|
||||
*/
|
||||
function readComicReaderProgress(storageProgress = {}, model = {}, chapterNumber) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
const storedByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? storageProgress.comicReaderByChapter
|
||||
: {}
|
||||
const storedChapter = (
|
||||
chapterId
|
||||
&& storedByChapter[chapterId]
|
||||
&& typeof storedByChapter[chapterId] === 'object'
|
||||
)
|
||||
? storedByChapter[chapterId]
|
||||
: {}
|
||||
const legacyHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
&& Array.isArray(storageProgress.completedHotspots[chapterId])
|
||||
)
|
||||
? storageProgress.completedHotspots[chapterId]
|
||||
: []
|
||||
const legacyFinished = (
|
||||
Array.isArray(storageProgress.completedChapters)
|
||||
&& storageProgress.completedChapters.includes(chapterId)
|
||||
)
|
||||
const chapterFinished = (
|
||||
typeof storedChapter.chapterFinished === 'boolean'
|
||||
? storedChapter.chapterFinished
|
||||
: legacyFinished
|
||||
)
|
||||
const isLastChapter = (
|
||||
Number(storageProgress.lastChapter) === Number(chapterNumber)
|
||||
)
|
||||
|
||||
return {
|
||||
currentPageId: (
|
||||
cleanString(storedChapter.currentPageId)
|
||||
|| (isLastChapter ? cleanString(storageProgress.lastPageId) : '')
|
||||
|| (chapterFinished ? cleanString(model.lastPageId) : '')
|
||||
|| cleanString(model.firstPageId)
|
||||
),
|
||||
completedEventIds: Array.isArray(storedChapter.completedEventIds)
|
||||
? [...storedChapter.completedEventIds]
|
||||
: [...legacyHotspots],
|
||||
chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge only the current chapter back into shared storage.
|
||||
*
|
||||
* Other chapters and unrelated product fields are copied through untouched.
|
||||
* The supplied reader progress is normalized again here so stale `finished`
|
||||
* flags and out-of-order future event ids cannot be persisted.
|
||||
*/
|
||||
function mergeComicReaderProgress(
|
||||
storageProgress = {},
|
||||
model = {},
|
||||
chapterNumber,
|
||||
readerProgress = {},
|
||||
) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
if (!chapterId || !isEightPageComicModel(model)) {
|
||||
return { ...storageProgress }
|
||||
}
|
||||
const state = buildComicReaderState(model, readerProgress)
|
||||
const normalized = progressFromState(state)
|
||||
const completedHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
)
|
||||
? { ...storageProgress.completedHotspots }
|
||||
: {}
|
||||
const comicReaderByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? { ...storageProgress.comicReaderByChapter }
|
||||
: {}
|
||||
const completedChapters = Array.isArray(storageProgress.completedChapters)
|
||||
? storageProgress.completedChapters.filter(
|
||||
(storedChapterId) => storedChapterId !== chapterId,
|
||||
)
|
||||
: []
|
||||
|
||||
completedHotspots[chapterId] = [...normalized.completedEventIds]
|
||||
comicReaderByChapter[chapterId] = normalized
|
||||
if (normalized.chapterFinished) completedChapters.push(chapterId)
|
||||
|
||||
return {
|
||||
...storageProgress,
|
||||
completedHotspots,
|
||||
completedChapters,
|
||||
comicReaderByChapter,
|
||||
lastChapter: Number(chapterNumber) || model.chapterNumber || 1,
|
||||
lastPageId: normalized.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transition helper. Completing an interaction unlocks the following
|
||||
* page but never turns it automatically, so the reader still controls the
|
||||
* lianhuanhua rhythm.
|
||||
*/
|
||||
function applyComicReaderAction(model, progress = {}, action = {}) {
|
||||
const state = buildComicReaderState(model, progress)
|
||||
if (!state.valid) return progressFromState(state)
|
||||
const type = cleanString(action.type)
|
||||
let nextProgress = progressFromState(state)
|
||||
|
||||
if (type === 'previous-page' && state.canGoPrevious) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex - 1
|
||||
].pageId
|
||||
} else if (type === 'next-page' && state.canGoNext) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex + 1
|
||||
].pageId
|
||||
} else if (type === 'open-page') {
|
||||
const targetIndex = pageIndex(model, action.pageId)
|
||||
if (targetIndex >= 0 && targetIndex <= state.unlockedPageIndex) {
|
||||
nextProgress.currentPageId = comicPages(model)[targetIndex].pageId
|
||||
}
|
||||
} else if (
|
||||
type === 'complete-event'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'event'
|
||||
&& state.activeInteraction.eventId === cleanString(action.eventId)
|
||||
) {
|
||||
nextProgress.completedEventIds = [
|
||||
...state.completedEventIds,
|
||||
state.activeInteraction.eventId,
|
||||
]
|
||||
} else if (
|
||||
type === 'complete-emotion'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'emotion'
|
||||
&& state.activeInteraction.emotionMomentId
|
||||
=== cleanString(action.emotionMomentId)
|
||||
) {
|
||||
nextProgress.chapterFinished = true
|
||||
}
|
||||
|
||||
return progressFromState(buildComicReaderState(model, nextProgress))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared illustration path is not proof that a finished page exists.
|
||||
* Callers must explicitly confirm formalAvailable after package/manifest
|
||||
* validation. Otherwise the state is visibly a fallback, never "formal art".
|
||||
*/
|
||||
function buildComicArtAvailability(page = {}, options = {}) {
|
||||
const formalAsset = cleanString(
|
||||
options.formalAsset || page.illustrationAsset,
|
||||
)
|
||||
const fallbackAsset = cleanString(
|
||||
options.fallbackAsset || page.fallbackAsset,
|
||||
)
|
||||
if (options.formalAvailable === true && formalAsset) {
|
||||
return {
|
||||
mode: 'formal-art',
|
||||
src: formalAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: true,
|
||||
isFallback: false,
|
||||
}
|
||||
}
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
mode: 'scene-fallback',
|
||||
src: fallbackAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'text-only',
|
||||
src: '',
|
||||
formalAsset,
|
||||
fallbackAsset: '',
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_PAGE_TYPES,
|
||||
applyComicReaderAction,
|
||||
buildComicArtAvailability,
|
||||
buildComicReaderState,
|
||||
getUnlockedPageIndex,
|
||||
isEightPageComicModel,
|
||||
mergeComicReaderProgress,
|
||||
normalizeCompletedEventIds,
|
||||
pageIndex,
|
||||
readComicReaderProgress,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 发布环境可覆盖这些值。
|
||||
*
|
||||
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
|
||||
* 使用包内种子图或返回文字回退,不会发起网络请求。
|
||||
*/
|
||||
module.exports = {
|
||||
cdnBaseUrl: '',
|
||||
imageCacheBudgetBytes: 28 * 1024 * 1024,
|
||||
audioCacheBudgetBytes: 32 * 1024 * 1024,
|
||||
audioCacheMaxEntries: 32,
|
||||
prefetchAhead: 2,
|
||||
downloadConcurrency: 2,
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
const { releaseAssets } = require('./releaseAssetManifest')
|
||||
|
||||
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
|
||||
const RUNTIME_VISUAL_TIERS = Object.freeze({
|
||||
FORMAL: 'formal',
|
||||
PROVISIONAL: 'experience-provisional',
|
||||
FALLBACK: 'fallback',
|
||||
})
|
||||
|
||||
// This set deliberately mirrors `usable` in
|
||||
// docs/production/art-release-gate-s01.json. Registered page art outside this
|
||||
// set may be shown only as experience-provisional and never counts as formal.
|
||||
const FORMAL_PAGE_ART_IDS = new Set([
|
||||
'S01-C01-P01',
|
||||
'S01-C01-P02',
|
||||
'S01-C01-P03',
|
||||
'S01-C01-P04',
|
||||
'S01-C01-P05',
|
||||
'S01-C01-P07',
|
||||
'S01-C01-P08',
|
||||
'S01-C02-P01',
|
||||
'S01-C02-P02',
|
||||
'S01-C02-P03',
|
||||
'S01-C02-P04',
|
||||
'S01-C02-P05',
|
||||
'S01-C02-P06',
|
||||
'S01-C02-P07',
|
||||
'S01-C02-P08',
|
||||
'S01-C03-P01',
|
||||
'S01-C03-P02',
|
||||
'S01-C03-P03',
|
||||
'S01-C03-P04',
|
||||
'S01-C03-P05',
|
||||
'S01-C03-P07',
|
||||
'S01-C03-P08',
|
||||
'S01-C04-P01',
|
||||
'S01-C04-P02',
|
||||
'S01-C04-P03',
|
||||
'S01-C04-P05',
|
||||
'S01-C04-P06',
|
||||
'S01-C04-P07',
|
||||
'S01-C04-P08',
|
||||
'S01-C05-P01',
|
||||
'S01-C05-P02',
|
||||
'S01-C05-P03',
|
||||
'S01-C05-P04',
|
||||
'S01-C05-P05',
|
||||
'S01-C05-P06',
|
||||
'S01-C05-P07',
|
||||
'S01-C05-P08',
|
||||
'S01-C06-P01',
|
||||
'S01-C06-P02',
|
||||
'S01-C06-P03',
|
||||
'S01-C06-P04',
|
||||
'S01-C06-P05',
|
||||
'S01-C06-P06',
|
||||
'S01-C06-P07',
|
||||
'S01-C06-P08',
|
||||
'S01-C07-P01',
|
||||
'S01-C07-P02',
|
||||
'S01-C07-P03',
|
||||
'S01-C07-P04',
|
||||
'S01-C07-P05',
|
||||
'S01-C07-P06',
|
||||
'S01-C07-P07',
|
||||
'S01-C07-P08',
|
||||
'S01-C08-P02',
|
||||
'S01-C08-P03',
|
||||
'S01-C08-P04',
|
||||
'S01-C08-P05',
|
||||
'S01-C08-P06',
|
||||
'S01-C08-P07',
|
||||
'S01-C08-P08',
|
||||
'S01-C09-P02',
|
||||
'S01-C09-P03',
|
||||
'S01-C09-P04',
|
||||
'S01-C09-P07',
|
||||
'S01-C09-P08',
|
||||
'S01-C10-P01',
|
||||
'S01-C10-P02',
|
||||
'S01-C10-P03',
|
||||
'S01-C10-P04',
|
||||
'S01-C10-P05',
|
||||
'S01-C10-P06',
|
||||
'S01-C10-P07',
|
||||
'S01-C10-P08',
|
||||
'S01-C11-P01',
|
||||
'S01-C11-P02',
|
||||
'S01-C11-P03',
|
||||
'S01-C11-P04',
|
||||
'S01-C11-P05',
|
||||
'S01-C11-P06',
|
||||
'S01-C11-P07',
|
||||
'S01-C11-P08',
|
||||
'S01-C12-P01',
|
||||
'S01-C12-P02',
|
||||
'S01-C12-P03',
|
||||
'S01-C12-P04',
|
||||
'S01-C12-P05',
|
||||
'S01-C12-P06',
|
||||
'S01-C12-P07',
|
||||
'S01-C12-P08',
|
||||
'S01-C13-P01',
|
||||
'S01-C13-P02',
|
||||
'S01-C13-P03',
|
||||
'S01-C13-P05',
|
||||
'S01-C13-P06',
|
||||
'S01-C13-P07',
|
||||
'S01-C13-P08',
|
||||
'S01-C14-P01',
|
||||
'S01-C14-P02',
|
||||
'S01-C14-P03',
|
||||
'S01-C14-P04',
|
||||
'S01-C14-P05',
|
||||
'S01-C14-P06',
|
||||
'S01-C14-P07',
|
||||
'S01-C14-P08',
|
||||
'S01-C15-P01',
|
||||
'S01-C15-P02',
|
||||
'S01-C15-P03',
|
||||
'S01-C15-P04',
|
||||
'S01-C15-P05',
|
||||
'S01-C15-P06',
|
||||
'S01-C15-P07',
|
||||
'S01-C15-P08',
|
||||
])
|
||||
|
||||
function clean(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicAssetIdForPageId(pageId) {
|
||||
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
|
||||
if (!match) return ''
|
||||
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
|
||||
}
|
||||
|
||||
function registeredPageArtFor(page) {
|
||||
const assetId = comicAssetIdForPageId(page && page.pageId)
|
||||
const releaseAsset = assetId ? releaseAssets[assetId] : null
|
||||
return Boolean(
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
&& clean(releaseAsset.localSeed)
|
||||
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
|
||||
)
|
||||
}
|
||||
|
||||
function createPageArtVisual(page, chapter, runtimeTier) {
|
||||
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
|
||||
return {
|
||||
kind: 'page-art',
|
||||
asset: clean(page.illustrationAsset),
|
||||
subject: clean(page.actorName || page.headline || chapter.title),
|
||||
runtimeTier,
|
||||
formalReleaseEligible: formal,
|
||||
reviewStatus: formal
|
||||
? PLAYABLE_VISUAL_REVIEW_STATUS
|
||||
: PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: formal
|
||||
? 'art-release-gate-s01-usable'
|
||||
: 'registered-immutable-asset-experience-only',
|
||||
}
|
||||
}
|
||||
|
||||
function personByInstanceId(chapter, instanceId) {
|
||||
return (chapter.people || []).find(
|
||||
(person) => (
|
||||
person.instanceId === instanceId
|
||||
&& clean(person.portrait)
|
||||
),
|
||||
) || null
|
||||
}
|
||||
|
||||
function personByName(chapter, name) {
|
||||
const cleanName = clean(name)
|
||||
if (!cleanName) return null
|
||||
return (chapter.people || []).find(
|
||||
(person) => person.name === cleanName && clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function featuredPersonForPage(page, chapter) {
|
||||
const direct = personByInstanceId(chapter, page.actorInstanceId)
|
||||
if (direct) return direct
|
||||
|
||||
if (page.type === 'memory') {
|
||||
const memoryPerson = personByName(
|
||||
chapter,
|
||||
page.memoryCard && page.memoryCard.characterName,
|
||||
)
|
||||
if (memoryPerson) return memoryPerson
|
||||
}
|
||||
|
||||
if (page.type === 'emotion') {
|
||||
const emotionPerson = personByName(
|
||||
chapter,
|
||||
chapter.emotionMoment
|
||||
&& chapter.emotionMoment.character
|
||||
&& chapter.emotionMoment.character.name,
|
||||
)
|
||||
if (emotionPerson) return emotionPerson
|
||||
}
|
||||
|
||||
return (chapter.people || []).find(
|
||||
(person) => clean(person.portrait),
|
||||
) || null
|
||||
}
|
||||
|
||||
function createPlayableVisual(page, chapter) {
|
||||
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
|
||||
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
|
||||
}
|
||||
|
||||
if (registeredPageArtFor(page)) {
|
||||
return createPageArtVisual(
|
||||
page,
|
||||
chapter,
|
||||
RUNTIME_VISUAL_TIERS.PROVISIONAL,
|
||||
)
|
||||
}
|
||||
|
||||
if (page.type === 'event') {
|
||||
const portrait = clean(page.actorPortrait)
|
||||
if (portrait) {
|
||||
return {
|
||||
kind: 'actor-portrait',
|
||||
asset: portrait,
|
||||
subject: clean(page.actorName),
|
||||
actorInstanceId: clean(page.actorInstanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.actorName),
|
||||
evidenceLabel: clean(page.headline || '看看手边证据'),
|
||||
evidenceDetail: clean(
|
||||
page.secondaryCaption || page.caption || page.question,
|
||||
),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPerson = featuredPersonForPage(page, chapter)
|
||||
if (featuredPerson) {
|
||||
return {
|
||||
kind: 'chapter-character',
|
||||
asset: clean(featuredPerson.portrait),
|
||||
subject: clean(featuredPerson.name),
|
||||
actorInstanceId: clean(featuredPerson.instanceId),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'season-character-continuity-sheet',
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive last resort. The season currently never reaches this branch,
|
||||
// but if a future chapter lacks a portrait it still gets explicit evidence
|
||||
// instead of an unexplained empty room.
|
||||
return {
|
||||
kind: 'evidence-composite',
|
||||
asset: clean(page.fallbackAsset),
|
||||
subject: clean(page.headline || chapter.title),
|
||||
evidenceLabel: clean(page.headline || chapter.title),
|
||||
evidenceDetail: clean(page.caption || chapter.narration),
|
||||
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
|
||||
formalReleaseEligible: false,
|
||||
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
|
||||
}
|
||||
}
|
||||
|
||||
function attachPlayableVisuals(pageSequence, chapter) {
|
||||
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
|
||||
...page,
|
||||
playableVisual: createPlayableVisual(page, chapter),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORMAL_PAGE_ART_IDS,
|
||||
PLAYABLE_VISUAL_REVIEW_STATUS,
|
||||
PROVISIONAL_VISUAL_REVIEW_STATUS,
|
||||
RUNTIME_VISUAL_TIERS,
|
||||
attachPlayableVisuals,
|
||||
createPlayableVisual,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Generated package-local production comic data. Do not hand-edit.
|
||||
module.exports = {
|
||||
"S01-C07": {
|
||||
"pages": [
|
||||
{
|
||||
"pageId": "S01-C07-P01",
|
||||
"type": "chapter-title",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C07-P01-title-v1.jpg",
|
||||
"caption": "饭馆开了张,最忙的桌边,有一句“我饱了”没人等到说完。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "翻开这一回",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C07-MT000",
|
||||
"S01-C07-MS001"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C07-P02",
|
||||
"type": "ensemble",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C07-P02-opening-ensemble-v1.jpg",
|
||||
"caption": "明远护住满碗,赵伯举勺添饭;唐守安回头看表,林秀兰守着墙边员工桌。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "先看看,谁在替别人决定,谁还愿意留下来听。",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C07-MS001",
|
||||
"S01-C07-MS002",
|
||||
"S01-C07-MS003"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C07-P03",
|
||||
"type": "event",
|
||||
"eventId": "S01-H25",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C07-P03-H25-full-bowl-v1.jpg",
|
||||
"caption": "大碗不是明远自己盛的。他已经说饱,双臂仍护着碗,怕再被要求吃到见底。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "孩子说饱了,仍要求他把大碗吃到见底,这样妥当吗?",
|
||||
"shortDialogue": "我真饱了。赵叔却说:碗底干净才是好孩子。",
|
||||
"hotspot": {
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"w": 61,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C07-MS002",
|
||||
"S01-C07-MS003",
|
||||
"S01-C07-MS006"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C07-P04",
|
||||
"type": "event",
|
||||
"eventId": "S01-H26",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C07-P04-H26-ladle-across-table-v1.jpg",
|
||||
"caption": "赵伯沿着旧年月的热心举起饭勺,没先问明远,就要用“能吃才壮”替孩子决定。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "用“男孩子能吃才壮”替孩子决定饭量,这样妥当吗?",
|
||||
"shortDialogue": "男孩子能吃才壮!明远把碗一收:可这是我的肚子。",
|
||||
"hotspot": {
|
||||
"x": 24,
|
||||
"y": 0,
|
||||
"w": 62,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C07-MS004",
|
||||
"S01-C07-MS005",
|
||||
"S01-C07-MS006"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C07-P05",
|
||||
"type": "event",
|
||||
"eventId": "S01-H27",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C07-P05-H27-door-and-watch-v1.jpg",
|
||||
"caption": "唐守安问了“还饿不饿”,明远刚抬头,他的目光已落到手表,脚也跨出了门。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "问了“还饿不饿”,却不等孩子答完就走,这样妥当吗?",
|
||||
"shortDialogue": "还饿不饿?明远刚抬头,他又说:回来再听你讲。",
|
||||
"hotspot": {
|
||||
"x": 28,
|
||||
"y": 0,
|
||||
"w": 63,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C07-MS007",
|
||||
"S01-C07-MS008",
|
||||
"S01-C07-MS009",
|
||||
"S01-C07-MS010",
|
||||
"S01-C07-MS011"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C07-P06",
|
||||
"type": "event",
|
||||
"eventId": "S01-H28",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C07-P06-H28-shift-table-v1.jpg",
|
||||
"caption": "林秀兰清空第十五桌,铺平错峰排班纸;有人来接班,员工才真正有时间坐下。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "开业忙时仍安排员工错峰坐下吃饭,这样更稳妥吗?",
|
||||
"shortDialogue": "不是写个“员工桌”就算数。谁几点坐下,得有人接他的活。",
|
||||
"hotspot": {
|
||||
"x": 22,
|
||||
"y": 0,
|
||||
"w": 59,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C07-MS013-ATTR",
|
||||
"S01-C07-MS013",
|
||||
"S01-C07-MS014"
|
||||
],
|
||||
"tableEcho": "",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C07-P07",
|
||||
"type": "emotion",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "S01-EM07",
|
||||
"assetName": "S01-C07-P07-EM07-listen-until-finished-v1.jpg",
|
||||
"caption": "门已经合上,明远仍护着满碗;这一次,桌边终于有人愿意等他说完。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "你愿意怎样把这句话接下去?",
|
||||
"shortDialogue": "",
|
||||
"hotspot": {
|
||||
"x": 25,
|
||||
"y": 0,
|
||||
"w": 57,
|
||||
"h": 99
|
||||
},
|
||||
"audioCueIds": [
|
||||
"S01-C07-MS011",
|
||||
"S01-C07-MS012",
|
||||
"S01-C07-TE900"
|
||||
],
|
||||
"tableEcho": "问出口的关心,要等到对方把话说完。",
|
||||
"cliffhanger": ""
|
||||
},
|
||||
{
|
||||
"pageId": "S01-C07-P08",
|
||||
"type": "memory-card-and-cliffhanger",
|
||||
"eventId": "",
|
||||
"emotionMomentId": "",
|
||||
"assetName": "S01-C07-P08-order-slip-cliffhanger-v1.jpg",
|
||||
"caption": "问出口的关心,要等到对方把话说完。",
|
||||
"secondaryCaption": "",
|
||||
"interactionPrompt": "收进我的桂香岁月",
|
||||
"shortDialogue": "",
|
||||
"hotspot": null,
|
||||
"audioCueIds": [
|
||||
"S01-C07-MS013-ATTR",
|
||||
"S01-C07-MS013",
|
||||
"S01-C07-TE900",
|
||||
"S01-C07-MS014"
|
||||
],
|
||||
"tableEcho": "问出口的关心,要等到对方把话说完。",
|
||||
"cliffhanger": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user